通用业务方案

限制接口访问权限

  1. 使用Spring Security进行权限控制

    • 配置Spring Security,为特定的接口添加权限控制,只允许特定的用户角色访问

    [配置实例]

    • 在这个例子中,/internal/** 路径下的接口只有具有"INTERNAL"角色的用户才能访问

  2. 使用IP白名单

    • 如果你希望接口只能被特定的IP地址访问,你可以在Spring Security配置中添加IP白名单。

    [配置实例]

    • 在这个例子中,只有来自IP地址192.168.1.100的请求才能访问/internal/**路径下的接口。

  3. 使用Spring Cloud Gateway或Zuul作为API网关

    • 在API网关层面控制访问权限,只允许内部服务访问特定的路由。

    [配置实例]

    • 在这个例子中,/internal/** 路径下的接口被配置在Spring Cloud Gateway中,并且可以进一步配置安全规则来限制访问。

  4. 在服务层面进行控制

    • 在服务代码中添加逻辑,检查请求的来源,例如通过特定的HTTP头或者请求参数来判断请求是否来自内部系统。

    [配置实例]

    • 在这个例子中,只有带有X-Internal-Request头且值为true的请求才能访问/internal/data接口

  5. 使用内部网络和防火墙规则

    • 将你的服务部署在内部网络中,并通过防火墙规则限制外部访问。这是一种常见的做法,可以确保只有内部网络中的服务可以访问特定的接口。

 

定时调度任务

基于Timer



  1. java.util.Timer的使用

    • void--->timer.schedule(TimerTask task, long delay, long period)

      • 功能解析:定义在定时任务线程创建以后在delay毫秒以后开始间隔period时间执行一次的定时任务,task是任务对象,TimeTask是抽象类,需要实现抽象run方法;delay是相对于定时调度线程启动时的初始延迟时间,单位是毫秒,period是两次执行的间隔时间,单位是毫秒

      • 使用示例

        • 示例含义:定时线程启动后初始延时5秒钟开始每隔十秒打印定时任务执行时间

      • 补充说明

        • 🔎TimerTask实现了Runnable接口,是一个抽象类,实例化对象需要实现其中的抽象run方法

    • void--->schedule(TimerTask task, long delay)

      • 功能解析:定义在定时任务线程创建以后在delay毫秒以后开始只执行一次的定时任务,task是任务对象,TimeTask是抽象类,需要实现抽象run方法;delay是相对于定时调度线程启动时的初始延迟时间,单位是毫秒,执行完一次定时任务线程就会销毁

      • 使用示例

        • 示例含义DistributedRedisLock是基于Redis+java.util.Timer+lua脚本来实现自动续期的可重入分布式锁实现,其中的renewExpire()方法就是使用Timer实现分布式锁自动续期的业务逻辑代码,这里面的又上一个续期任务执行成功唤醒下一个定时续期任务的思想值得借鉴,这样可以避免出现意外导致定时任务停不下来

      • 补充说明

        • 🔎:该方法一般用在控制定时任务线程需要满足一定条件才能继续执行避免定时任务因为意外情况无法自动停止的场景,比如分布式锁的自动续期

 

基于延迟队列

  1. 订单业务逻辑

  2. 带回滚的锁库存逻辑

    • 数据库mall-sms中的表sms_stock_order_task记录着当前那个订单正在锁库存,在表sms_stock_order_task_detail表中记录着商品id、锁定库存的数量、锁定库存所在仓库id、订单任务号;即锁库存的时候先给数据库保存要锁定的库存记录,然后再锁定库存;只要锁定库存成功,库存相关的三张表因为本地事务都会成功保存锁库存记录;如果锁失败了数据库因为事务不会有锁库存记录,库存也不会锁定成功

    • 这样库存锁定表中存在的就是下单成功锁定库存的记录和下单失败但是锁定库存成功的记录,我们可以考虑使用一个定时任务,每隔一段时间就扫描一次数据库,检查一下哪些订单没有创建但是有锁定库存的记录,把这些锁定库存记录拿出来重新把库存补偿回滚一下,但是使用定时任务来定期扫描整个数据库表是很麻烦的一件事,我们通过引入延时消息队列来实现定时功能

    • 延时队列的原理是当库存锁定成功以后我们将库存锁定成功的消息发送给延时队列,但是在一定时间内消息被暂存在延时队列中不要往外发送,即锁定库存成功的消息暂存在延时队列中一段时间,在订单支付时间过期以后我们将该消息发送给解锁库存服务,解锁库存服务去检查订单是否被取消,如果订单根本没有创建或者因为订单未支付而被自动取消了就去数据库根据对应的锁库存记录将库存解锁,即锁定的库存在订单最大失效时间以后才固定进行解锁,通过延时消息队列来控制这个定时功能

    • 延时队列锁库存的业务流程

      • 1️⃣:创建订单锁定库存成功后,给主题交换器stock-event-exchange发送库存工作单消息,消息的路由键stock.locked,被主题交换器路由到队列stock.delay.queue中等待50分钟过期时间

      • 2️⃣:库存工作单在消息存活时间到期以后,队列将消息的路由键更改为stock.release,通过主题交换器stock-event-exchange将消息路由到队列stock.release.stock.queue,由该队列将消息转发给消费者库存服务

      • 3️⃣:消费者库存服务收到库存工作单消息,检查订单服务对应订单的状态,如果订单未被成功创建或者订单未支付就解锁被锁定的库存

    • 解锁库存逻辑

      • 需要解锁库存的场景:

        • 创建订单成功,订单过期没有支付被系统自动取消或者订单被用户手动取消时需要解锁库存

        • 创建订单过程中,远程调用库存服务锁定库存成功,但是调用其他服务时出现异常导致创建订单整个业务回滚,之前成功锁定的库存就需要自动解锁来实现回滚,使用Seata分布式事务性能太差,不适合下单这种高并发场景;基于柔性事务的可靠消息加最终一致性的分布式事务方案,在保证分布式事务下的性能同时,允许一定时间内的软一致性并确保库存数据的最终一致性

      • 只要库存锁定成功就给RabbitMQ中对应的库存延迟队列发送库存工作单消息,使用RabbitTemplate.convertAndSend()发送锁定库存消息,同时锁定库存以前我们要保存库存工作单信息[对应表wms_ware_order_task,保存订单Id、订单号],锁定存库成功以后要保存库存工作单详情[对应表wms_ware_order_task_detail,给该表添加bigint类型字段ware_id锁定库存所在仓库id;int类型的lock_status,其中1表示已锁定、2表示已解锁、3表示已扣减;注意MP更改了字段需要更改相应的Mapper文件中的resultMap标签;保存商品sku、商品数量、库存工作单id、锁定库存所在仓库id、默认锁定状态是已锁定1],锁定库存前先保存库存工作单,保存库存工作单是为了追溯锁定库存信息

        • 给消息队列发送的消息实体类直接写在common包下,该消息对象StockLockedTo保存库存工作单id、该工作单下所有工作单详情id列表,老师这里发送消息的时机错了,所有商品都锁定成了才给消息队列发送消息,否则本地事务会自动回滚,老师是锁定一个商品就发送一条消息,如果事务回滚了发出去的消息就撤不回来了,而且老师这里发送的消息是全量的库存工作单详情数据

        [消息]

        [锁定库存保存库存工作单并发送消息]

      • 使用@RabbitHandler监听锁定库存消息队列,获取到消息对象,按以下情况执行解锁库存逻辑

        • 1️⃣:创建订单过程中,库存锁定成功,但是接下来创建订单出现问题,整个订单回滚,被锁定的库存需要自动解锁

          • 只要库存工作单详情存在,就说明库存锁定成功,此时我们就要看订单状态来判断是否需要解锁库存,如果订单都没有说明订单没有被成功创建,此时就要使用库存工作单详情来解锁库存

          • 如果有订单查看订单状态,如果订单已经被取消就解锁库存,只要订单没有被取消就不能解锁库存,订单被取消字段status等于4

            • 根据库存工作单的id去订单服务查询订单实体类,如果订单不存在或者订单的status字段为4就调用unLockStock方法解锁库存

        • 2️⃣:订单创建失败是由于库存锁定失败导致的

          • 库存工作单数据没有是库存本地事务整体回滚导致的,库存工作单记录不会创建,锁定库存操作也会全部自动回滚,这种情况无需解锁

        • 解锁库存需要知道商品的skuId、锁定库存所在仓库id、锁定库存数量、库存工作单详情id,解锁就是将原来的增加的锁定库存的字段再减掉UPDATE wms_ware_sku SET stock_locked = stock_locked + #{num} WHERE sku_id=#{skuId} AND ware_id = #{wareId}

          • 🚁:自动应答的消息队列,一旦在消息的消费过程中出现异常导致消息无法被正常消费,消息就丢失了,比如Feign远程调用网络闪断,或者远程服务的Feign调用必须携带用户的登录状态但是实际请求没有携带用户登录状态被远程服务拦截,抛出异常终止后续方法执行,此时消息就彻底丢失了

          • 📓:使用配置spring.rabbitmq.listener.simple.acknowledge-mode=manual开启消息接收手动应答,在订单解锁成功以后使用方法channel.basicAck(message.getMessageProperties().getDeliveryTag(),false)来做消息接收手动应答,解锁成功立马手动应答,无需解锁什么也不用做立马手动应答,只要在应答后发生异常也不会导致消息丢失;如果远程调用没有成功返回在库存解锁以前出现问题,我们使用方法channel.basicReject(message.getMessageProperties().getDeliveryTag(),true)来手动拒绝消息并将消息重新放到队列中,给别人继续消费消息解锁库存的机会,比如由于分区故障为了保证一致性部分服务不可用

        • :订单服务所有远程调用请求都要求有登录状态,但是我们的消息队列监听方法的远程调用不可能带用户登录状态,因此我们需要在订单服务的拦截器中放行所有消息队列监听方法调用的订单服务远程接口,特别注意,这个需要被放行的请求路径还拼接了请求参数导致请求路径是动态的

          • 🔑:我们通过在拦截器中放行指定URI的请求来实现这个目的,对于URI是变化的我们使用Spring提供的boolean match = AntPathMatcher.match("/order/order/status/**",request.getRequestURI())[HttpServletRequest.getRequestURI()是获取请求路径的URI,HttpServletRequest.getRequestURL()是获取请求路径的URL],如果请求URI匹配我们需要的格式就直接通过拦截器的return true放行,无需再进行用户登录状态检查

        • 专门抽取一个消息队列的监听器Service来处理消息队列中的消息

          • 在类上标注@RabbitListener(queues="stock.release.stock.queue")来监听指定队列,在类上标注@Service注解将该类的实例化对象作为容器组件,在具体的方法上标注注解@RabbitHandler,在该方法中调用库存服务实现的解锁库存逻辑,解锁库存的方法出现任何异常都手动拒绝消息并重新入队列,只要解锁库存方法成功调用就手动应答接收消息,远程调用如果状态码不是0说明没有查到对应订单的实体类,此时直接抛异常执行拒绝接收消息的逻辑

          [监听队列消息]

          [解锁库存]

        • 解锁库存成功后通过库存工作单详情id将库存工作单详情的状态lock_status更改为已解锁2,增加前面解锁库存的条件只有库存工作单详情为已锁定状态且需要解锁时才能解锁库存

      • 带回滚的锁库存实现

        • 给库存服务mall-ware引入、配置RabbitMQ并在主启动类上标注@EnableRabbit开启RabbitMQ功能

        • 配置RabbitMQ的消息的JSON序列化机制

        • 给库存服务添加一个默认交换器stock-event-exchange

          • 交换器使用Topic交换器类型,因为该交换器需要绑定多个队列,而且还需要使用对不同消息的路由键进行模糊匹配的功能

        • 给库存服务添加一个释放库存队列stock.release.stock.queue,支持持久化,不支持排他和自动删除,普通队列不需要设置参数

        • 给库存服务添加一个延迟库存工作单消息的队列stock.delay.queue,给该延时队列设置死信交换器stock-event-exchange,设置死信的路由键为stock.release,设置队列的消息存活时间为120秒[方便测试用的,比验证订单创建的延迟队列多一分钟],支持持久化,不支持排他和自动删除

        • 给库存服务的库存释放队列和交换器添加一个绑定关系,绑定目的地stock.release.stock.queue,交换器stock-event-exchange,绑定键stock.release.#

        • 给库存服务的库存延时队列和交换器添加一个绑定关系,绑定目的地stock.delay.queue,交换器stock-event-exchange,绑定键stock.delay.#

        • 监听一个队列让以上所有容器组件都通过SpringBoot自动去RabbitMQ中检查创建

        • 取消订单逻辑

          • 订单服务队列和交换器组件和绑定关系

          • 订单创建成功就给交换器order-event-exchange发送消息,消息路由键order.create.order,保存的消息是OrderCreateTO.getOrder(),消息会被交换器路由到order.delay.queue[队列中的消息延时时间为30min],消息变成死信后路由键配置成order.release.order并将消息转发到order-event-exchange路由到order.release.order.queue被订单服务监听,订单服务监听接收取消订单并将消息转发以路由键order.release.other通过交换器order-event-exchange,队列将消息转发给订单服务

            [订单服务创建订单发起消息]

            [订单服务接收消息取消订单并向库存服务发送消息]

          • 订单服务收到消息根据订单id查询数据库对应的订单状态,如果订单状态为订单创建对应的状态码,将订单状态更改为取消订单对应状态码

          • 通过监听消息队列消息在取消订单或者订单创建失败的情况下解锁库存

            [消息队列监听]

            [解锁库存]

          • 我们这里是用库存解锁时间大于取消订单时间来实现解锁库存只要订单的状态为已取消或者订单没有成功创建,就释放已经锁定的库存,但是这种方式存在很严重的问题;比如订单创建成功,但是由于各种原因,消息延迟了很久才发给消息队列,但是库存一锁定成功就将消息发送给消息队列了,导致解锁库存的消息比取消订单的消息先到期,这时候就会导致解锁库存的消息被消费,库存因为订单处于新建状态无法解锁,即使后续订单被解锁了库存也无法被解锁了;即一旦发生意外导致解锁库存的消息比取消订单的消息先到,就会发生被锁定的库存永远无法解锁的情况

            • 🔑:让订单服务取消订单后再发一个消息路由键为order.release.other给交换器order-event-exchange,我们为交换器order-event-exchange和队列stock.release.stock.queue设定绑定关系,绑定关系设定为order.release.other.#,让取消订单的消息被队列stock.release.stock.queue发送给消费者库存服务。库存服务用@RabbitListener(queues="stock.release.stock.queue")监听同一个队列stock.release.stock.queue,用@RabbitHandler标注的方法监听消息类型为OrderTo,在原来解锁库存的逻辑中判断,当前库存是否解锁过,没解锁过就解锁,解锁过就不用解锁了,老师的逻辑是根据订单号查询库存工作单,根据库存工作单找到所有没有解锁的库存工作单详情调用此前解锁库存的方法进行解锁,感觉这里老师的实现不好,自己实现这部分代码

        • 实际上解锁库存是订单取消的时候解锁一次,锁定库存成功以后一定时间再解锁一次

       

消息可靠性投递

  1. 影响消息可靠性的因素

    • 消息丢失:消息丢失在电商系统中是一个非常可怕的操作,比如订单消息丢失可能会影响到后续一连串比如商家确认、解锁库存、物流等等各种信息,消息可能发生丢失的原因如下:

      • 消息从生产者发送出去,但是由于网络问题抵达RabbitMQ服务器失败,或者因为异常根本没有发送成功

        • 这时候可以用try...catch语句块来发送消息,发送失败在catch语句块中设置重试策略

        • 同时给数据库创建一张消息数据库表mq_message,建表语句如下

        • 只要消息发送失败就给数据库存上这么一条日志,定期扫描数据库来检查消息日志状态来重新发送消息

      • 消息到达Broker,消息只有被投递给队列才算持久化完成,一旦消息还没有到达队列,RabbitMQ服务器宕机消息就会因为还没有来得及持久化而发生丢失

        • 开启生产者消息抵达队列确认,只要消息没有成功抵达队列就会触发生产者的returnCallback回调,消息不能成功抵达应该设置消息重试发送和向数据库记录消息日志

        • 开启生产者消息确认回调,只要消息成功抵达RabbitMQ服务器就触发该回调

      • 自动ACK的状态,消费者收到消息,但是消息没有被成功消费,比如消费消息或者消费消息前出现异常或者服务器宕机,自动应答的消息会直接丢失

        • 开启手动ACK,消息成功消费以后再手动应答接收消息,消息消费失败就手动拒绝消息让消息重新入队列,注意消息没有被应答即没有手动拒绝RabbitMQ没有收到应答的消息也会默认重新入队列再次发送

      • 🔎:防消息丢失的核心就是做好消息生产者和消息消费者两端的消息确认机制,主要策略就是生产者的消息抵达确认回调和消费者的手动应答,凡是消息不能成功抵达服务端和消费端的消息都做好消息日志记录,定期扫描数据库,将发送失败的消息定期重新发送

    • 消息重复:就是因为各种原因导致的消息重新投递

      • 消息消费成功,事务已经提交,但是手动Ack的时候机器宕机或者网络连接中断导致手动Ack没有进行,RabbitMQ的消息因为没有收到应答自动将消息重新入队列并将消息状态从Unack状态变成ready状态,并再次将消息发送给消费者

      • 消息消费过程中消费失败又再次重试发送消息,注意啊,虽然我们让消息消费失败消息拒绝重新入队列

        • 解决办法是业务消息消费接口设计成幂等性接口,比如解锁库存要判断库存工作单详情的状态位,消息消费成功修改对应状态位

        • 使用redis或者mysql防重表,将消息和业务通过唯一标识联系起来,业务被成功处理过的消息就不用再处理了

        • RabbitMQ的每个消息都有一个redelivered消息属性字段,每个消息都可以通过Boolean redelivered = message.getMessageProperties().getRedelivered()判断当前消息是否被第二次或者第N次重新投递过来的,这个一般做辅助判断,因为谁也不能保证消息在第几次消费被消费成功

    • 消息积压:消息队列中的消息积压太多,导致消息队列的性能下降

      • 消费者宕机导致消息积压

      • 消费者消费能力不足,比如活动高峰期,比如消费者宕机导致的消费者集群消费能力不足,有服务完全不可用消息反复重入队列消息肯定会积压,应该设置重试次数,投递达到重试次数消息就被专门的服务处理比如存入数据库离线处理

        • 注意消费者没有应答消费消息,队列中的消息处于Unack状态,生产者会不停报错,让CPU飚高,非常消耗系统性能,这个问题要想办法防一下

      • 发送者发送消息的流量太大,超出消费者的消费能力

        • 限制发送者的流量,让服务限流业务进不来就能限制发送者的流量,不过只是因为消息中间件或者消费者能力有限就限制业务有点得不偿失

        • 上线更多的消费者增强消息的消费能力

        • 上线专门的消息队列消息消费服务,将消息批量从消息队列中取出来,直接写入数据库,缓解消息队列压力,然后再缓慢离线从数据库中获取消息离线处理

        • 消息队列集群

  2. 一般都是把消息中间件专门做成一个服务,叫数据中台,负责消息发送和自动记录消息日志,消息发送失败自动进行重试,将消息发送的所有功能都考虑周到,其他服务通过调用该服务来实现消息的发送,看老师的意思,一般消息发送成功也得记录日志,这个可以作为防止消息丢失更进一步的手段,毕竟会影响性能

  3. 生产者抵达确认带数据库保存失败消息

  4. 消费者手动ACK

  5. 使用MP数据库消息日志记录

    • 同时给数据库创建一张消息数据库表mq_message,建表语句如下

    • MP插入消息记录

      [实体类]

      [持久化接口]

      [持久化接口对应xml]

      [业务实现类]

       

基于Quartz



  1. 定时任务的执行时刻

 

单点登录

 

开源项目

  1. 开源的单点登录demo

    • 码云搜索徐雪里/xxl-sso,这是一个XXL社区提供的分布式单点登录框架,下载压缩包或者克隆到本地

    • 项目目录结构

      • xxl-sso-core是核心包

      • xxl-sso-server是登录中心服务器

      • xxl-sso-samples是一些简单的例子,有基于cookiesessionxxl-sso-web-sample-springboot,也有基于tokenxxl-sso-token-sample-springboot,这两个就是下面使用逻辑的客户端

    • 认证中心配置文件解析[配置文件在目录xxl-sso-server中,后面没有特殊说明都在该目录下]

    • 测试目录xxl-sso-samples下客户端的配置文件解析

      [xxl-sso-web-sample-springboot]

      • 使用命令mvn clean package -Dmaven.skip.test=true,如果单独对这个子项目打包,注意这个子项目依赖核心包xxl-sso-core,在对xxl-sso-web-sample-springboot进行打包的时候会去本地仓库找对应的核心包依赖,只打包当前项目是不会安装到本地仓库的,因此需要先在核心包xxl-sso-corepom.xml所在目录下使用命令mvn install将该核心包安装到本地仓库中,这种自定义的所有被依赖包必须在本地仓库有了才能对当前项目进行打包

        • 我们可以直接在父项目pom.xml所在使用命令mvn clean package -Dmaven.skip.test=true一次性将所有的子项目都打包好,这个就是谷粒商城的手动打包方式,如果是整体打包,打包子项目就不需要单独将被依赖的包安装到本地仓库中

      • 使用命令java -jar xxl-sso-web-sample-springboot-1.1.1-SNAPSHOT.jar --server.port=8081来启动测试实例项目

        • 注意可以在配置文件更改服务的端口,也可以在启动命令中更改服务实例的端口

      • 使用命令java -jar xxl-sso-web-sample-springboot-1.1.1-SNAPSHOT.jar --server.port=8082来启动测试实例项目

  2. 框架业务使用示例和逻辑

    • 我们准备三个顶级域名ssoserver.comclient1.comclient2.com,我们将ssoserver作为登录认证服务器,将client1.comclient2.com作为我们的小系统,用来模拟跨顶级域名的单点登录测试效果,小系统就是上面说的xxl-sso-samples目录下

    • 使用SwitchHost更改三个域名都映射到本机,如果不使用SwitchHost需要更改路径C:\windows\System32\drivers\etc\hosts

    • 启动服务xxl-sso-server,我们在启动的时候需要将源文件打包,pom.xml中直接聚合了三个目录,我们可以一次性将整个项目都打包了,在pom.xml所在目录打开cmd窗口,使用命令mvn clean package -Dmaven.skip.test=true清包打包跳过Maven测试[即使没有打过包的也要进行清包,其他项目也是如此,避免出现莫名其妙的问题],打完包直接使用java -jar命令启动

    • 通过请求路径http://ssoserver.com:8080/xxl-sso-server访问认证中心

    • 分别使用两个端口启动项目xxl-sso-web-sample-springboot,注意把配置文件给改对了,要保证redis服务器的地址和认证中心的地址正确

    • 分别通过请求路径http://client1.com:8081/xxl-sso-web-sample-springboothttp://client2.com:8082/xxl-sso-web-sample-springboot访问一个测试服务两个服务实例

    • 验证任意一个认证中心登录,所有不同顶级域名的服务实例都登录,任意一个服务实例登出,所有不同顶级域名的服务实例都登出

 

 

接口幂等性

  1. 方案一:Token令牌机制

    • 最常见的场景是验证码场景,比如买票锁定座位12306需要我们输入验证码,请求中只有验证码正确的情况下才能锁定座位成功,而且验证码使用一次就失效

    • 应用在提交订单业务上我们可以在给用户响应订单确认页的同时下发一个Token令牌,服务器存储了该令牌,用户提交订单的时候携带该令牌,只要提交订单的请求携带了该令牌,我们就验证通过创建订单,只要一次验证令牌通过服务器就删除该令牌,用户的多次提交最终只有一个请求能验证通过

    • 如何比较完美地执行验证令牌操作才能尽可能保证不出错,主要考虑是先删令牌再创建订单还是先创建订单再删除令牌,

      • 如果是后删令牌问题非常大,前一个请求处于创建订单期间第二个请求就开始执行令牌验证操作,由于前一个请求订单还没创建令牌还没来得及删除,第二个请求会验证通过,此时第二个请求也会开始创建订单,因此我们一般首选先删令牌

      • 如果是先删令牌

        • 可能存在订单没有成功创建服务器宕机导致订单创建失败的问题;

        • 同时分布式场景下服务器的令牌不会直接存在本地缓存,一般都是存在redis中,从Redis中存取数据就会存在延迟,此时如果两个请求间隔时间很短,两个请求都成功从Redis中获取到令牌,同时验证成功,也会发生接口的幂等性问题;因此我们一般会要求验证令牌时获取令牌、验证令牌和删除令牌三个操作是一个原子性操作,我们可以使用lua脚本if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end来保证三步操作的原子性,让验证操作直接在Redis中原子性一步执行

  2. 方案二:锁机制

    • 数据库悲观锁

      • select * from xxx where id=1 for update;来使用数据库行级锁,悲观锁一般结合事务一起使用,定位记录的查询条件一定要设置成主键或者唯一键索引,否则很容易造成锁表,锁表处理起来非常麻烦

    • 数据库乐观锁

      • 数据库乐观锁update t_goods set count=count-1,version=version+1 where goodId=2 and version=1;更适合更新场景,给数据库表添加一个版本字段,只有获取的数据版本和更新时的数据版本相同更新操作才会成功,更新操作会单增数据版本,只要一次数据成功更新,多次请求中后续到达的请求就无法再修改数据了[这种场景很适合库存数据的更新,比如第一次操作库存服务更新了库存数据并更新了版本号,但是操作库存数据的请求响应给订单服务时出现了网络问题,订单服务再次调用库存服务,但是订单服务最初获取的库存数据版本是旧的,库存数据的版本已经发生了更新,后续的重试不论执行多少遍都不会成功,我们可以使用额外信息来判断更新操作的具体订单,乐观锁适合读多写少的业务场景]

    • 分布式锁

      • 多台服务实例执行相同的写操作,我们对被操作对象上分布式锁,拿到锁的机器执行写操作,写的同时给数据一个标志位,当其他机器拿到锁以后先检查标志位发现数据已经发生了写操作就直接释放锁不再执行写操作了

  3. 方案三:唯一约束

    • 唯一键索引:比如给用户的订单确认页下发唯一标识并保留在服务器内部,给该唯一标识对应的字段添加唯一索引,生成订单向数据库插入该唯一标识,只有第一次生成的订单数据因为唯一键索引能成功写入数据库,后续插入订单记录因为唯一键约束就会插入失败

    • Redis的set防重:数据只能被处理一次,我们可以在处理数据时计算数据的MD5并将MD5密文存入Redis的set中,每次处理数据前先检查一下数据的MD5是否已经存在,如果已经存在就不处理了,百度网盘的秒传功能就是这样的

  4. 方案四:防重表

    • 上面的Redis防重也算防重表的一种

    • 比如建立一个去重表,使用订单号orderNo作为去重表的唯一键索引,将订单号插入去重表再做业务处理,保证订单号插入去重表和业务处理在同一个事务中;这样因为去重表中有订单号作为唯一键索引,多次提交的后续请求就会因为无法向去重表插入数据导致请求失败,业务处理不会执行;同时即使去重表数据插入成功,只要业务处理失败因为事务也会将去重表的数据回滚,让订单的后续重试能够继续进行

      • 因为要保证去重表和业务表的整体事务,需要将去重表和业务表放在同一个数据库中

  5. 方案五:给请求下发全局唯一标识ID

    • 调用接口时我们可以给请求指定一个全局唯一的ID,接口处理该请求的时候将全局唯一的请求ID存储到Redis中,处理业务请求的时候如果发现该请求标识已经存在了我们就不再对该请求进行处理直接返回成功

    • 这种请求全局唯一标识ID还可以做服务调用链路追踪,追踪请求经过了哪些服务

    • Nginx给每个请求分配唯一Id的配置proxy_set_header X-Request-Id $request_id;,这个一般做链路追踪,不能做防重处理,因为Nginx给每个请求分配的全局唯一ID都是不一样的,即重复提交的每个请求Nginx都会给请求分配一个全局的唯一ID;我们自定义的请求可以参考这种思路给请求头设置一个全局唯一的请求标识ID,特别是做Feign的远程调用请求,重试的请求请求头中的唯一标识ID都是一样的

 

Token令牌方案示例



  1. 前端业务逻辑

    • 我们给订单确认页返回一个Token令牌,这里为了简单直接使用UUID,用一个type属性值为hiddeninput框以token作为输入框的值来保存该Token令牌,这样能直接在表单提交时自动作为参数上传

    • 给分布式session中即Redis中存放该Token令牌,这里为了避免发生恶意对Token令牌乱用,同时session自动就有有效时间,用户不关浏览器但是一段时间不操作Token令牌就会自动失效,用户没有确认订单关闭了浏览器Token令牌也会直接失效,老师是使用session中存储的用户id加token的名字的方式作为key存储的,value存储token本身

    • 封装提交订单时上传的数据[为了防止前端数据上传恶意篡改导致的数据错误引起问题纠纷],给提交订单按钮做一个form表单,所有要提交的数据都做成隐藏的input框,实际上该项目只提交了收货地址、订单最后价格、防重令牌

      • 封装用户收货地址的ID,页面初始化或者用户切换收货地址的时候将输入框的值进行回填$("#addrIdInput").val(addrId)

      • 封装用户的支付方式Integer

      • 商品无需提交,再去购物车获取一遍商品[京东也是这样做的,这样可以避免确认订单请求被恶意篡改,就是我们先调出订单确认页,此时已经计算了购物车中的选中商品价格,我们再修改购物车被选中商品,此时总价肯定发生了变化,我们在原来的订单确认页点击提交订单,我们发现最新的价格变成了购物车选中商品后的价格,这说明两个情况,第一是京东提交订单以后是重新获取购物车数据来计算最终价格的,第二是京东没有对订单确认页的商品金额做验价],重新计算金额

      • 防重令牌,做接口幂等性校验,页面模板引擎渲染时赋值

      • 封装订单确认页总价,提交订单后重新获取购物车商品数据计算价格以后对价格进行校验,如果校验通过说明购物车中被选中的商品在订单确认期间没有发生变化,如果校验没通过说明购物车中的商品发生了变化,我们可以通知用户购物车商品发生了变化,让用户注意一下,计算应付总价时赋值

      • 订单备注信息,这个这里没实现,有需要再实现

  2. 后端创建订单逻辑

    • 视图跳转逻辑

      • 下单成功跳转支付页

      • 下单失败携带异常信息跳回订单确认页重新确认订单

    • 验证令牌,创建用户订单,校验价格,锁订单库存

       

本地事务

  1. 本地事务

    • 数据库事务的特性[ACID]:

      • 原子性[一系列操作整体性不可拆分,即要么整体成功、要么整体失败]

      • 一致性[整体数据操作前后守恒]

      • 隔离性[或独立性,事务之间相互隔离,一个业务操作失败回滚不会影响其他业务操作]

      • 持久性[事务一旦成功提交,数据就一定会落盘到数据库,认为是先落盘再提示事务成功提交]

    • 本地事务的应用场景是单体应用连接一个数据库,没有多个数据库、没有涉及服务拆分、也没有涉及服务间的远程调用

  2. Spring提供的本地事务注解@Transactional注解

    • Spring框架提供了一个@Transactional注解来使用本地事务

    • 隔离级别:隔离级别是SQL数据库规定的一些规范

      • READ UNCOMMITTED[读未提交]:设置该隔离级别的事务可以读到其他未提交事务的数据,这会导致脏读现象[比如读未提交的数据被回滚了,但是其他操作已经拿到回滚前的数据进行后续的计算]

      • READ COMMITTED[读已提交]:设置该隔离级别的事务可以读取已经提交事务的数据,这是OracleSqlServer的隔离级别,特点是每次读取都读取的是实际值,同一系列操作中两次读取的数据不同都是实际值

      • REPEATABLE READ[可重复读]:设置该隔离级别的事务读取到的数据是事务开始时的数据,事务期间读取的数据都是相同的,这是MYSQL默认的隔离级别,特点是存在幻读现象,即实际数据在业务处理期间已经发生变化,但仍然使用的事务开启时的数据

        • mysqlInnoDB引擎可以通过next-key locks即行锁算法机制来避免幻读

      • SERIALIZABLE[序列化]:设置该隔离级别的事务全是串行顺序执行的,MySQL数据库的InnoDB引擎会给读操作隐式加一把读共享锁,避免脏读、不可重复读、幻读问题,但是这也意味着使用这种隔离级别数据库操作就失去了并发能力

      • 🔎通过@Transactional(isolation=Isolation.READ_COMMITTED)可以指定当前事务的隔离级别

    • 事务传播行为[Spring中的事务管理行为]:事务的传播行为就是指被调用方法的事务和调用方法的事务之间的关系,比如a方法需要开启一个事务,b方法也需要开启一个事务,a方法调用b方法,那么a,b方法开启的事务之间的关系

      • PROPAGATION_REQUIRED:如果当前业务操作还没有事务,就创建一个新事务;如果当前业务操作已经存在事务就加入该事务,一旦事务中任何一处失败事务中的所有操作都回滚

        • 一旦b方法的事务设置成PROPAGATION_REQUIRED,如果b方法被开启了事务的a方法调用,b方法的事务配置就会完全失效,比如b方法单独设置了代码执行超过7s就会回滚@Transactional(propagation=Propagation.REQUIRED,timeout=7),a方法设置了代码执行超过30s就会回滚,b方法被a方法调用,那么b方法的事务配置就会直接失效,即a事务的设置会自动传播到和a方法共用一个事务的方法

      • PROPAGATION_REQUIRED_NEW:不论当前业务操作有没有事务,都为被@Transactional注解标注的方法创建一个新的事务,该方法出现问题只会回滚该方法的业务操作,其他事务出问题不会影响该方法的事务行为

      • PROPAGATION_SUPPORTS:如果当前业务操作已经存在事务就加入该事务,如果当前业务操作还没有事务就以非事务的方式执行

      • PROPAGATION_MANDATORY:如果当前业务操作已经存在事务就加入该事务,如果当前业务操作还没有事务就直接抛出异常

      • PROPAGATION_NOT_SUPPORTED:以非事务的方式执行当前被标注方法,如果当前业务操作已经存在事务,就将当前事务挂起

      • PROPAGATION_NEVER:以非事务的方式执行当前被标注方法,如果当前业务操作已经存在事务,就直接抛出异常

      • PROPAGATION_NESTED:如果当前业务操作已经存在事务,被标注方法会在当前事务内部创建一个子事务,子事务的特点是会在子事务开启的时刻创建一个保存点,子事务失败只会回退到该保存点,不会回滚整个事务,父事务中的其他操作仍然能正常执行;但是如果父事务回滚,子事务也会一起回滚;如果当前业务操作还没有事务就为被标注方法创建一个全新的事务

        • 这个事务传播行为和PROPAGATION_REQUIRED_NEW很像,被标注方法的事务失败不会影响业务操作的其他事务,主要区别如下;PROPAGATION_REQUIRES_NEW会挂起当前事务,并创建一个全新的事务,这意味着新事务与原事务完全独立,事务成功或者失败不会相互影响。而PROPAGATION_NESTED则是在当前事务内部创建一个子事务,如果父事务被回滚,子事务也会被回滚,但子事务的回滚不会影响到父事务。

  3. SpringBoot中的本地事务

    • SpringBoot中也是默认使用Spring的本地事务注解@Transactional

    • SpringBoot中使用@Transactional注解的坑

      • SpringBoot中,如果a方法、b方法、c方法是同一个Service中的方法,三个方法都标注了@Transactional注解且都做了个性化配置,比如都各自配置了不同的事务传播行为和超时时间,此时如果a方法调用了b方法和c方法,b方法和c方法的任何事物配置都不会生效,包括事物传播行为,都是和a方法共用同一个事务

      • 🔑:​这是AOP的问题,在之前SpringCache中就出现过,那儿老师没有解释,事务注解是通过AOP实现的,事务是通过代理对象orderService来控制的,如果直接调用同一个类中的实例方法本质上相当于跳过代理对象直接通过方法名调用同一个类中的方法,就类似于代码的复制粘贴,被调用的方法事务注解不会生效;根本原因就是绕过了代理对象,这一块不太熟,后面复习的时候深入理解一下,a方法是通过代理对象调用的,但是b方法和c方法只是单纯地将代码复制粘贴过来,我们通过orderService来调用被标注了基于AOP实现的事务或者缓存注解的方法对应的注解会生效,但是同一个类下的代码的相互调用是类似于直接复制拷贝代码的形式,通过this来调用也是没用的,this也会被处理成同一个对象从而直接复制拷贝被调用方法的代码,只有通过代理对象类似于orderService.a()来调用事务注解或者缓存注解才会生效,总之基于AOP实现的注解都需要通过代理对象来调用才会生效,通过this调用也是不生效的;而且一定不要企图在orderService中自动注入orderService来通过代理对象调用b方法或者c方法,这相当于orderService依赖于orderService,在构造orderService将其注入容器时会发现构造orderService自身有一个属性需要注入orderService,会造成循环依赖的问题,系统启动的时候就要爆炸

      • 🔑:同一个对象内基于AOP实现的注解标注方法相互调用注解功能失效的解决办法,核心是要使用代理对象来调用标注了基于AOP实现的注解的方法

        • 引入Spring的AOP动态代理场景启动器spring-boot-starter-aop,引入该场景启动器的目的是使用其依赖的aspectjweaver,这个动态代理更加强大

        • 在配置类上使用注解@EnableAspectJAutoProxy开启Aspectj动态代理,不使用该注解默认使用的是JDK默认的按照接口自动生成的动态代理,使用该注解所有的动态代理都是Aspectj创建的,使用Aspectj的好处是即使没有接口也可以创建动态代理;在@EnableAspectJAutoProxy中指定exposeProxy属性为true@EnableAspectJAutoProxy(exposeProxy=true)来对外暴露代理对象

        • 只要设置了通过Aspectj创建代理对象,我们就可以在任何地方通过org.springframework.aop.framework.AopContext即AOP上下文的AopContext.currentProxy()拿到当前代码所在对象对应的代理对象[Object类型,需要强转为当前代码所在对象的类型来调用所在对象的b方法或者c方法],直接通过代理对象来调用b方法或者c方法,这样b方法和c方法的事务注解包括此前的缓存注解才会生效,示例方法如下所示:

  4. 分布式环境下本地事务存在问题

    • 虽然我们给创建订单和锁定库存都分别添加了@Transactional本地事务注解来各自开启单体事务,但是因为订单服务和库存服务处在不同的服务实例,因此事务不能跨服务生效,订单成功创建但是库存没有成功扣减,只会回滚库存成功锁定商品的记录,无法回滚已经创建订单记录,这是单体事务的局限

      • 解决办法,我们直接根据远程调用的结果判断,在创建订单记录的服务中判断锁库存的状态,如果锁库存失败,我们直接在订单服务抛异常来让订单服务的事务进行回滚,这样也能控住分布式事务,让多个数据库一起回滚

    • 但是通过抛出异常和单体事务结合的方案不能完美解决事务问题

      • 被调用服务成功执行,调用服务可能由于网络中断、调用超时导致被调用服务成功执行,调用服务回滚[这种情况叫远程服务假失败]。因为被调用服务本身可能不会出现异常正常执行,但是由于网络中断,系统卡死导致响应超时等都会导致在调用方抛出异常,这样就会导致远程调用成功添加记录,但是调用方由于网络,远程调用超时而抛出异常来回滚,导致订单相关数据没有一起回滚;比如锁定库存服务响应慢,库存锁定成功了,但是订单服务远程调用超时,订单服务感知到远程调用出问题了抛异常事务回滚,但是远程服务正常执行不会进行回滚,就会出现订单取消了,但是库存给锁定了

      • 已经被调用并成功执行的远程服务在订单创建失败的情况下无法自动进行回滚。远程服务调用期间没有出现问题,方法执行结束事务就已经结束了,后续订单服务运行期间出现任何问题导致需要回滚,已经执行完毕的远程服务无法自动进行事务回滚,我们想要手动回滚还需要专门写释放对应库存记录的方法

    • 本地事务在分布式系统下只能控制住本地连接的事务回滚,控制不了其他服务和连接的事务回滚,在分布式系统下,本地事务控制不住事务的根本原因是网络中断+不同数据库+服务实例集群,而本地事务只能控制一个连接内的事务

 

分布式定理

CAP定理

  1. CAP定理:一个分布式系统中,以下三个要素最多只能同时实现两个,不可能三者兼顾

    • 一致性[Consistency]:同一时刻对分布式系统中的任意一个节点的某个数据进行访问,一定是获取的最新的相同的值,形象地说就是任意一个数据节点更新某个数据完成时间点以后,访问任意一个数据节点都获取的是更新后的数据或者数据副本,注意这个一致性说的是强一致性,即任何时间点访问任何机器上的数据都是确定相同的值

      • 从客户端角度,多进程并发访问时,更新过的数据在不同进程中的获取策略决定了不同的一致性,对于关系型数据库不同的一致性要求如下

        • 强一致性:更新成功的数据能够被后续所有请求唯一访问

        • 弱一致性:更新成功的数据,系统能够容忍后续部分请求或者全部请求都访问不到

        • 最终一致性:更新成功的数据,经过一段时间弱一致性后所有的请求都能唯一访问,即我们能忍受一段时间的弱一致性

      • 分布式事务就是围绕我们想要系统维持什么样的一致性来设计的不同的几种方案

    • 可用性[Availability]:集群中部分节点故障后,集群仍然能响应客户端的读写请求;不可用就是系统某个环节出现了需要等待节点修复以后才能使用的情况

    • 分区容错性[Partition tolerance]:不同节点上的服务相互通信需要通过网络,只要网络通信出现了故障就可以认为发生了分区错误,专业的角度来说,一个分布式系统分布在多个子网络上,每个子网络就是一个区;分区容错的意思是区之间的通信可能会失败,比如中国的网络是一个区,美国的网络是一个区,两个区的网络可能无法通信

    • 举一个例子说明CAP理论:

      • 假如一个MySQL主从集群,A节点是主节点,B节点和C节点是从节点,一旦A节点和C节点之间发生网络分区故障,主节点的数据更新就无法同步到从节点上,如果此时还要保证C节点的可用性,就会发生从C节点上读取到的数据都是错误数据的情况,从而导致系统一致性得不到保证;

      • 在分布式系统中我们永远要满足分区容错性,因为网络通信肯定会出现问题,网络出现问题我们还要保证系统运行就是满足分区容错,因此我们就只能选择满足一致性或者选择满足可用性,当发生分区故障,导致不同数据不一致,我们需要根据业务场景选择是牺牲一致性满足可用性[允许部分数据不一致]还是牺牲可用性满足一致性[不允许部分数据不一致而让数据不一致的服务不可用或者直接让系统不可用来代替单个节点的不可用]

      • 因为分区容错无法避免,可以认为分布式系统下CAP理论的P总是成立[因为无法保证网络不中断,除非单体应用且数据库Redis等都装在一台机器中],CAP理论指出在保证分区容错的情况下,一致性和可用性无法同时做到,只可能同时满足CP或者AP

        • 满足AP即满足分区容错的前提下满足可用性,即让三个节点都正常运行,取到数据不一致无所谓,业务正常执行

        • 满足CP即满足分区容错的前提下满足一致性,即让系统不可用或者让数据错误的节点不可用

          • 分布式系统下实现一致性的Raft算法、paxos算法

      • CAP定理在实际开发中面临的普遍问题是

        • 大型互联网应用场景,主机众多,部署分散,集群规模越来越大;节点故障和网络故障是常态,对商用服务还要充分保障可用性,系统不可用特别像阿里这种基础服务设施短时间的不可用就是特大事故,因此我们还需要保证系统的可用性达到N个9,在很多时候都要保证分区容错和可用性,舍弃掉强一致性;

        • 此时就从CAP理论延伸出BASE理论,核心思想即使我们在保证分区容错和可用性的前提下无法做到强一致性,但是我们可以适当地采取弱一致性,弱一致性就是最终一致性

Base理论

  1. Base理论

    • 基本可用[Basically Avaliable]:基本可用是指分布式系统在出现故障时,允许损失诸如响应时间、部分功能这样的部分可用性,来保证整个系统的可用性

      • 响应时间损失:正常情况下系统0.5s内响应客户查询请求,在系统部分机房断点或断网的情况下,查询响应时间可以增加到1-2s

      • 功能上损失:电商网站在购物高峰期为了保护系统的稳定性,部分消费者可能会被引导到一个降级页面[服务降级页面,比如一个错误页,Sentinel学过;这个思路学Nginx的时候听说过,秒杀抢购活动实际上早就决定好了哪些请求失败,因为一般这种流量都会提前打开对应活动界面,在活动界面埋点就能预测具体请求数量甚至直接在前端就决定哪些请求直接失败,到时刻以后直接跳转错误页,只有很少的流量到达了上游服务器]

    • 软状态[Soft State]:软状态是相对于强一致状态而言的,强一直是业务操作中的每个操作要么整体成功、要么整体失败,软状态是整个业务操作正在同步中;

      • 典型应用场景就是分布式存储中一份数据一般会有多个副本,允许不同副本的延时同步就是软状态的体现。Mysql的异步复制即mysql replication就是软状态的一种体现

    • 最终一致性[Eventual Consistency]:最终一致性是指系统中所有的数据副本经过一定时间后最终能够达到一致的状态[注意核心概念是能够,像不同节点获取到不同的数据并使用该数据继续一泻千里最终得到完全混沌的数据是不能经过一段时间达到一致状态的]

      • 比如在我们的订单服务中,我们使用单体事务控制分布式系统的远程调用事务,库存服务成功扣减库存,但是订单回滚,库存服务无法回滚,我们可以不要求强一致性,即订单创建失败就必须让库存也回滚,我们可以在订单出现异常回滚前的时刻给消息中间件发送一条消息,指定要解锁的库存数量,一段时间后库存服务接收到消息释放解锁对应库存即可

      • 我们可以使用各种手段来保证最终一致性,事缓则圆嘛

Raft算法

  1. Raft算法

    • Raft是一种协议,可以保证分布式系统下的一致性,Raft的原理演示流程查看http://thesecretlivesofdata.com/raft/配合老师的课程会理解的更清晰,作用是即使网络出现了分区故障,系统的一致性仍然能得到保障

    • 分布式系统一致性:当数据库只有一个节点时一致性非常容易满足,只需要一个节点更新数据成功后读取到的数据就是最新的数据;关键是在数据库集群场景下如何保证数据库集群整体的数据一致性

    • 主节点选举:Raft算法把系统中的每个节点分成三种状态,follower从节点、candidate候选者、leader主节点;数据库集群启动时每个节点都是以从节点的状态启动的,如果从节点在一定时间内没有监听到主节点发送来的心跳包就会主动变成候选者状态[变成候选者是为了准备当主节点];候选者会给集群中的每个节点都发起一个投票请求,集群中每个收到请求的从节点都会投票给该候选者,而且从节点只要投过一次票在一次心跳时间内就不会再给后续的投票请求投票;候选者只要收到包含自己在内的大多数节点的同意投票就会变成主节点状态,这就是主节点的选举过程;

      • Raft中有两个超时时间用来控制主节点选举过程,一个是选举超时时间[超过指定时间没有收到主节点的心跳包当前节点就会成为候选节点,让其他从节点选举候选节点做主节点,选举超时时间一般是150-300ms,也称节点的自旋时间,节点的自旋时间是随机的],一个是心跳超时时间[主节点向从节点发送心跳包的间隔时间,该心跳超时时间一般远小于每个节点的自选时间,只要从节点收到主节点的心跳包就会重置各自的自旋时间来避免成为候选节点],节点内部有一个Term属性记录当前节点发起投票的轮数,候选者也会给自己投一票并且将投票轮数递增1[注意投票轮数从集群启动开始设置为0,只要发起一次投票就会累加1,不会出现重置的情况],其他从节点如果还没有投票就会将票投给该候选者并重置自身的自旋时间,后续的投票请求不会同意投票,只要主节点收到绝大多数从节点的同意投票就会变成主节点,主节点开始给所有从节点通过心跳包发送追加日志,并周期性地向所有从节点发送心跳包,一直维持该状态直到主节点宕机[准确地说是有一个从节点变成了候选者节点,就是有一个节点超过自旋时间没有接收到心跳包],此时又开始重复主节点选举流程,此时新的主节点的数据和旧的主节点数据是一致的

      • 节点宕机必然导致集群内的节点数量变成偶数,此时就可能存在两个节点成为候选节点并只都获取到半数投票[实际上可能存在N个候选节点,且N个候选节点都没有获取到半数以上的投票,概率小,因为自旋时间比较长,远超内网通信时间],此时因为没有候选者节点获取到半数以上投票将无法选举出主节点,注意节点在自旋时间结束后新的自旋周期开启时发起投票请求并重新开始自旋时间计时,各个节点会因为自旋时间内没有成为主节点和没有收到主节点的心跳包而在自旋时间结束后再次发起投票,重复上述过程直到某个节点成功获取到大多数节点的投票,因为必须要获取到大多数节点的投票才能成为主节点,因此系统内只可能存在一个主节点;只可能在网络分区故障多个分区之间无法通信,此时才会存在集群分裂和多个主节点的现象

    • 日志复制:一旦主节点选出来以后,所有对该集群系统的更新都会通过给主节点发送请求来实现,从节点自身不具备更新功能;主节点每一个数据写操作都会被追加为一个节点日志,生成日志时主节点的更新操作还没有提交,此时客户端访问主节点的数据仍然获取的是更新前的数据;主节点生成节点日志后复制节点日志给所有的从节点,从节点收到日志并更新了本地数据后会响应更新成功状态给主节点;主节点只要确认大多数节点更新成功就会直接提交本次更新并再次向所有从节点发起提交请求

      • 每个日志都是通过主节点的心跳包发送出去的,主节点发生写操作追加更新日志,会统一在下一个心跳包中奖日志携带给各个从节点,从节点同步日志并回复主节点,只要大多数节点回复主节点成功同步主节点就会直接提交更新结果并同时将更新成功的状态响应给客户端,在下一个心跳包中向所有从节点发起提交请求,所有从节点提交更新结果并将提交状态响应给主节点

    • 网络分区故障Raft保障数据一致性的原理

      • 假设A、B两个节点处于一个网络分区,C、D、E三个节点处于一个网络分区,两个网络分区发生了网络分区故障,两个分区之间节点的网络通信断掉了;假如原来5个节点的主节点是B节点,一旦出现网络分区,B主节点和另一个网络分区的从节点无法通信,但是因为B主节点所在网络分区和从节点可以正常通信,因此B主节点的状态不会发生变化;C、D、E三个从节点因为收不到主节点的心跳包重新选举主节点,假如选举出主节点E,此时所有节点的数量就变成了3,特别注意此时旧的主节点B因为仍然维持主节点状态,所有节点的数量仍然维持5,只有在选举成为主节点时才会更新集群中的节点数量;

      • 此时相当于两个分区的节点分裂成两个集群,主节点所在集群仍然维持旧的主节点,另一个全是从节点的分区重新选举主节点,旧的主节点可能因为所在分区节点数小于成为主节点时节点数的一半,从而导致旧的主节点所在分区永远无法正确响应客户端的请求,但是另一个分区因为重新选举了主节点重新确定了集群节点数量因此会正常处理所有客户端请求并在一个分区中维持数据一致性

      • 当网络分区故障恢复以后,此时会出现两个主节点,两个主节点都会互相给对方发送心跳包,当主节点发现对方的选举轮数Term的值比自己大,就会皇帝退位,即旧的主节点B发现对方的选举轮数比自己多就会变成从节点,原来B主节点所在分区节点收到两个心跳包并发现有轮数更大的主节点就会将轮数更大的主节点视为新的主节点,B主节点所在分区的节点会将没有提交的数据全部回滚并重新同步新的主节点E的更新日志,此时网络故障恢复,整个集群又处于一致性状态了

      • 这个演示只适合旧的主节点所在网络分区的节点数小于绝大多数节点的情况,此时旧的主节点无法提交更新数据因而整个分区无法处理用户请求从而保障系统数据一致性;但是如果旧的主节点所在网络分区的节点数大于绝大多数节点要求那岂不是也可以提交更新的数据,那岂不是会变成两个集群[因为这种思路逻辑有问题,对上面的情形换一种思路理解,即集群总的节点数量在集群启动时就确定,网络分区一般发生在两个分区之间,两个分区会将可通信的节点数量分成两份,一方节点数量大于一半另一方的节点数量就必然小于一半,即始终只有一个一个集群能成功提供服务,另一个集群因为节点数小于一半而无法提交更新结果从而无法提供服务;但是这也存在问题,因为两个网络分区的存活节点数也可能刚好相同,那这种情况岂不是两个集群都无法对外提供服务系统直接崩溃,这里需要看一下相关论文确认]

    • raft.github.io也有一个动画演示,但是都没有展示两个网络分区节点数各占一半情况下由于不满足半数以上节点同步更新导致无法各分区主节点无法提交导致整个系统无法对外提供服务的情况,看论文可能会有收获

      • 这个问题老师后面提了,说这正是CAP定理对Raft算法的约束,即要保证集群的一致性,当两个集群的节点数目无法达到半数以上,虽然基本上节点都存活着,但是此时集群因为主节点无法获取大多数节点的响应而无法提交更新后的数据因此整个集群都无法为客户端提供服务,保证一致性并降低对可用性的影响

 

分布式事务

 

  1. 2PC模式[2 phase commit,也称二阶段提交]

    • 2PC模式也叫XA Transactions,MySQL从5.5版本开始支持、SQL Server从SQL Server 2005开始支持、Oracle从Oracle 7开始支持

    • 二阶段提交协议的思想是将事务拆分成两个阶段,其中涉及两个对象事务管理器和本地资源管理器[本地资源管理器可以视为每个服务的事务管理器],这里把本地资源管理器看成两个功能不同服务的事务管理器

      • 二阶段提交是将事务分成两个阶段,第一个阶段是准备提交阶段,事务管理器对业务相关的每一个微服务的事务管理器发起请求要求各事务管理器检查本地的事务提交就绪状态[本地数据是否准备好、数据库连接是否正常以及能否正常提交数据],如果各个服务都能正常提交每个服务都会响应就绪状态给事务管理器

      • 第二个阶段是提交阶段,如果事务管理器都成功接收到业务操作涉及到的本地资源管理器的就绪状态确认,就会给每个本地资源管理器发起提交请求,所有本地资源管理器统一提交事务并响应成功状态给事务管理器;一旦有任何一个数据库在预备阶段否决此次提交、所有相关数据库都会被要求回滚本次事务中的那部分数据,事务管理器就会要求所有的本地资源管理器全部进行回滚

    • XA协议最大的特点是简单,而且基本上商用的数据库都实现了XA协议,使用分布式事务的成本很低;但是注意mysql数据库对XA协议的支持不是很好,mysql的XA实现没有记录预备阶段[prepare]的日志,主备数据库切换会导致主库和备库的数据不一致

    • XA协议最大的问题是性能不理想,特别是交易下单这种并发量很高的服务调用链路,XA协议无法满足高并发场景;很多NoSQL数据库也没有支持XA协议,这就使得XA协议的应用场景变得非常狭隘

    • 除了二阶段提交还有三阶段提交,额外引入了超时机制,无论无论是事务管理器还是本地资源管理器,向对方发起请求后,超过一定时间没有收到回应会执行兜底处理[这里老师说的三阶段提交是将预备阶段分成两个阶段,第一个阶段询问各个本地资源管理器能否正常提交,第二个阶段是本地资源管理器准备数据,第三个阶段就是正常的提交阶段]

    • XA协议在应用中能解决一些问题,但是应用的不多,主要是了解

  2. 柔性事务[TCC模式,也称TCC事务补偿型方案]

    • 这个方案在分布式事务中经常使用,柔性事务是一类保证最终一致性的方案的统称

    • 刚性事务:遵循数据库ACID原则的强一致性要求的事务

    • 柔性事务:遵循BASE理论的最终一致性要求的事务,柔性事务允许一定时间内不同节点的数据不一致,但是最终各个节点内的数据一致

    • 柔性事务流程图

      • 假如有两个数据库,一个数据库对应订单服务,另一个数据库对应库存服务,每个数据库都由对应的服务来进行操作

      • TCC模式要求开发人员在编写代码的时候在服务中实现三个方法Try[该方法是准备要提交的数据]Confirm[该方法是用于提交数据]Cancel[该方法是回滚准备提交的数据,老师这里说的是开发人员前面提交了数据比如数据加2,这个取消方法就要将数据减2]作为可能被回调的方法

      • 第一个阶段,主业务服务[调用各个远程服务的大业务所在服务]命令各个服务调用开发者编写的Try方法来准备数据,同时启动业务活动管理器记录业务操作、并通过业务活动管理器控制提交和回滚业务活动

      • 第二阶段,业务活动管理器命令各个服务调用Confirm方法提交数据

      • 第三个阶段,只要提交过程有任何一个远程调用服务或者主业务服务执行失败,业务活动管理器就会命令所有的远程服务触发开发者自己编写的Cancel方法来做手动回滚补偿,已经成功提交的数据我们再手动进行恢复

    • 这种模式在电商项目中使用的非常多,基于TCC模式实现的事务框架也非常多,只需要按照框架的接口规范把业务方法拆分成三个部分,分别实现数据准备、提交数据、回滚数据的三个方法,框架会自动在特定的节点对三种方法进行调用,这个方案的核心就是出现问题对已经成功提交的数据采用手动补偿的方式来实现回滚

    • TCC模式相当于3PC模式的手动版,3PC相当于自动准备提交的数据、自动进行提交和自动进行回滚,TCC相当于程序员自己实现准备提交数据、提交、回滚的逻辑

  3. 柔性事务[最大努力通知型方案]

    • 主业务比如创建订单业务远程调用库存服务锁库存成功,远程调用订单服务保存订单数据成功,但是比价的时候失败了,此时订单创建失败,注意此时订单数据和库存数据都已经提交;我们可以让主业务给消息队列中的主题交换器发送消息给队列,让所有相关服务都来订阅消息队列,库存服务收到消息去解锁库存,订单服务收到消息去解锁订单

    • 我们害怕消息发出去了但是消息丢失,我们可以逐渐拉长时间间隔给消息队列中发送消息,设置最大消息通知次数,达到最大通知次数就不再发送订单创建失败消息通知;或者服务手动回滚即释放库存即删除订单数据成功了就将消息响应给主业务服务,此时主业务服务收到回滚确认以后就不再向消息队列发送消息

    • 这种多次通知、主业务确认手动回滚的特点也是最大努力通知型方案命名的原因

    • 这种方式适合使用在与第三方系统通讯的场景,比如调用微信或者支付宝支付后的结果通知,各大交易平台间的商户通知、多次通知,查询校对、对账文件、支付宝支付成功后的异步回调等;支付宝付款就是支付成功以后会多次给我们的服务器发送支付成功的消息给我们的订单业务

    • 通过消息队列来实现延时回滚的策略都是通知型方案,保证最终一致性来提升系统的可用性,实际生产中也常使用第三和第四种结合消息队列多次失败通知回滚数据并回复消息生产者的方案

  4. 柔性事务[可靠消息+最终一致性方案,也称异步确保型]

    • 这个讲的不清楚,说后面会补充案例说明

    • 大体意思是业务事务提交前,会实时给消息服务保存一份消息数据,但是消息数据在得到确认发送的指令前不会发送给远程调用服务,只有在业务事务提交后才会消息服务发出确认发送指令,这里讲的不清楚,后面结合场景理解一下

    • 老师在这里的解释和上面第三种是一样的,大业务失败给消息队列发送消息,被调用服务收到消息就回滚数据

  5. 方案优缺点分析

    • 第三和第四种方案的好处是可以支持大并发场景,订单服务失败只需要发送消息给消息中间件,无需等待其他服务数据回滚就能直接响应用户请求,通过多次发送和回滚验证确认接口来等待远程服务的回滚状态确认,一旦得到确认就停止消息的发送来实现最大努力通知

 

Seata分布式事务



  1. Seata分布式事务控制原理

    • 术语

      • TC:事务协调者,作用是维护全局和分支事务状态,驱动全局事务提交或者回滚,通过TC协调各个远程服务是否一起提交事务或者回滚事务,这个TC就类似于XA二阶段协议的事务管理器,主要是作为全局的协调者

      • TM:事务管理器,定义全局事务范围,开始全局事务、提交或回滚全局事务

      • RM:资源管理器,资源管理器位于各个服务中,直接和当前服务对应数据库交互,就是类似于单体Spring中使用的@Transactional

      • 三者的整体关系是事务管理器负责开启全局事务,TC事务协调者负责协调全局事务中牵扯的各个分支事务,

    • 工作流程

      • 创建订单业务的事务管理器准备开启一个全局事务,向事务协调者声明开启一个全局事务,事务协调者响应收到;

      • 订单业务所在服务调用远程服务的时候,远程服务的RM资源管理器会向事务协调者声明一个分支事务并且需要实时报告分支事务状态,无论任意一个分支事务提交还是回滚,事务协调者都会实时知道;注意分支事务是在订单业务调用远程服务执行业务代码时开启的,而且调用结束分支事务就已经提交了

      • 远程调用时任意一个分支事务回滚,事务协调器会直接命令其他已经提交过的分支事务也回滚

      • 使用注解@GlobalTransactional标注在业务方法上即可使用seata分布式事务

  2. Seata的使用方法

    • 1️⃣:如果我们使用Seata的AT自动事务模式需要创建一张数据库表UNDO_LOG[回滚日志表]

      • 因为自动事务模式下数据的回滚由Seata进行控制,每个分支事务在远程调用结束前分支事务就已经提交了,回滚只能通过反向补偿的方式重置数据;使用TCC模式是自己来定义反向补偿的代码,TA自动事务模式就是该代码由Seata实现和调用,Seata需要在每一个数据库中都要额外准备一个回滚日志表,回滚日志表记录着此前给哪个数据库表的哪个记录做了什么更新,恢复以前的状态就是对以前的更新操作进行反向补偿,这种方式叫魔改数据库

      [回滚日志表]

      • 给每个数据库都要创建下面这张回滚日志表

    • 2️⃣:从地址https://github.com/seata/seata/releases下载Seata服务器软件包,Seata服务器就是负责全局协调的事务协调者TC

      • 老师下载的是windows的seata-server-0.7.1.zip,老师说1.0.0版本的用法和0.x.x版本的用法不一样

    • 3️⃣:在pom.xml中导入依赖​com.alibaba.cloud:spring-cloud-starter-alibaba-seata

      • 在IDEA项目的External Libraries中存放着我们引入的第三方依赖,其中的com.alibaba.cloud:spring-cloud-starter-alibaba-seataseata的源码中的GlobalTransactionAutoConfigurationseata全局事务配置

      • com.alibaba.cloud:spring-cloud-starter-alibaba-seata依赖于io.seata:seata-all,这个就是seata的TC事务协调器对应的依赖,这个依赖的版本必须和seata的TC服务器[就是seata-server]版本保持一致,com.alibaba.cloud:spring-cloud-starter-alibaba-seata:2.1.0.RELEASE对应io.seata:seata-all:0.7.1

    • 4️⃣:在windows本机解压seata-server-0.7.1.zip得到seata-server-0.7.1,目录结构如下

      • binseata-server的命令行目录

        • seata-server.bat:双击启动windows上的事务协调器,seata-server在注册中心上的服务名是serverAddr[新版本或者docker中叫seata-server],注意seata-serverJDK的版本有要求,JDK版本不对CMD启动会直接报错

      • confseata-server配置目录,用户能自定义配置的两个文件是file.confregistry.conf

        • db_store.sql:使用seata相关功能涉及到的所有数据库表sql

        • registry.conf:注册中心相关配置[有这个配置文件是因为seata服务器也想把自身注册到注册中心里面作为系统的一部分,registry是配置注册中心信息的,config是对seata服务器进行配置]

          • registry

            • 支持的注册中心类型被列举在type字段上方,包含file 、nacos 、eureka、redis、zk、consul、etcd3、sofa默认是file,我们使用的是nacos,需要修改为nacos

            • serverAddr:指定nacos的注册中心服务器地址

          • config

            • 支持的配置方式都被列举在type字段上方,包含file 、nacos 、eureka、redis、zk、consul、etcd3、sofafile表示使用本地的配置根目录下的file.confseata相关配置,nacos表示使用配置中心上的配置文件来做配置,其他的依次代表使用对应配置中心上的配置文件来做seata服务器的配置,我们使用file.conf直接在seata服务器本地的file.confseata服务器的相关配置

        • file.conf

          • transportseata的数据传输配置,type="TCP"表示使用TCP传输协议,server="NIO"服务器采用NIO的数据传输模式,heartbeat=true表示开启服务器心跳,thread-factory是线程工厂配置

          • service

          • clientseata客户端配置

          • store是事务日志存储配置,mode="file"表示使用的配置方式,默认支持file[事务日志文件存储在seata服务器,store中的file标签配置事务日志文件存储的目录dir和日志文件大小]db[事务日志文件存储在数据库中,store中的db标签配置事务日志文件存储的数据库地址url,用户名user和密码password,全局事务日志表名global.table、分支事务表名branch.table和锁表名lock-table,这三张表就是db_store.sql中的三张表],使用数据库存储需要去数据库创建对应三张表,我们这里图方便直接在seata服务器本地存储事务日志文件,什么都不需要管很方便

      • lib

    • 5️⃣:给需要使用分布式事务的业务方法标注全局事务注解@GlobalTransactional[注意只有调用远程方法的业务方法才需要标注全局事务注解,只是作为被调用方法只需要标注本地事务即可],注意每个服务内部的方法本地控制事务仍然要标注本地事务注解@Transactional

      • 老师说的只要给分布式大事务标注全局事务注解@GlobalTransactional注解就行,每个远程小事务都使用本地事务注解@Transactional,但是我这里有疑问,如果远程调用方法调用了远程服务,那么这个远程服务是否还需要标注全局事务注解@GlobalTransactional

      • @GlobalTransactional注解中配置了事务超时时间timeoutMills,要回滚的异常rollbackFor,无需回滚的异常noRollbackFor

      • 其他步骤按照文档地址https://seata.apache.org/zh-cn/docs/user/quickstart/补足剩余操作,

      • https://github.com/apache/incubator-seata-samples/tree/master/at-sample中有seata与各种场景下比如dubbo、MyBatis、springcloud-jpa-seata的整合示例,在每个服务实例中的README.md中是对应应用场景的使用方法介绍,以springcloud-jpa-seataspringcloud-jpa应用场景整合seata为例

        • 除去介绍的在快速开始已经完成的准备工作,还需要额外注入一个DataSourceProxy容器组件,该组件是seata的代理数据源,seata想要控制住事务需要通过seata包装默认的数据源让seata来代理数据源才能实现使用seata控制事务的目的

    • 6️⃣:所有使用seata分布式事务的微服务都需要使用seataDataSourceProxy代理默认的数据源

      • 具体实现是手动给数据库注入一个默认要使用的数据源,然后通过该数据源组件再创建注入一个数据源代理对象DataSourceProxy组件,并使用@Primary注解将数据源代理对象作为主数据源,注意下面这个配置在SpringBoot低版本可用,但是在SpringBoot2.0以后容易引起循环引入异常

      • 注意:在Seata0.9版本以后,提供了DataSource默认代理的功能,并且默认是开启的,不用再手动的去把DataSource放入到DataSourceProxy中了

      • SpringBoot默认数据源配置DataSourceAutoConfiguration

      • 通过模仿SpringBoot初始化数据源的方式来初始化数据源并使用seata的数据源代理对象来包装数据源组件

        • 注意导入了数据源代理对象,该代理对象中保存了数据源HikariDataSource的信息,因为系统中有了DataSource,因此默认的HikariDataSource组件不会再自动注入了

    • 7️⃣:将seata服务器下的conf目录下的registry.conffile.conf给每个服务的类路径下都拷贝一份

      • file.conf文件中的service.vgroup_mapping配置更改成和当前服务的spring.application.name保持一致即service.vgroup_mapping.mall-order-fescar-service-group="default"[在 org.springframework.cloud:spring-cloud-starter-alibaba-seataorg.springframework.cloud.alibaba.seata.GlobalTransactionAutoConfiguration类中,默认会使用 ${spring.application.name}-fescar-service-group作为服务名注册到Seata-Server上,如果和file.conf中的配置不一致,会提示 no available server to connect错误,我们也可以通过配置 spring.cloud.alibaba.seata.tx-service-group修改这个默认后缀,但是该配置必须和file.conf中的配置service.vgroup_mapping.xxx这个xxx保持一致]

  3. Seata的局限性

    • SeataAT模式不适用于高并发场景,适合使用在保存商品信息这种并发量不高的场景,保存商品信息需要远程调用库存服务、优惠券服务;此时就适合使用Seata来做分布式事务控制;就是SeataAT模式适合用在后台管理系统这种并发量不太高的场景做分布式事务控制

    • 像下单这种典型的高并发场景就不适合使用SeataAT模式,SeataAT模式在事务进行期间要获取全局锁、会将全局事务的业务变成串行执行,所有人都需要等待上一个订单创建完才能执行创建下一个订单,这样系统就没法使用了,因此高并发场景下一般不考虑使用XA二阶段提交模式,也不会考虑TCC手动事务补偿模式;

    • 高并发场景下更多的考虑基于可靠消息投递加最终一致性的异步确保型的最大努力通知型方案,因此我们的订单服务不使用Seata分布式事务解决方案,而选择使用柔性事务中的可靠消息投递+最终一致性的异步确保型方案

      • 即使用Seata来控制分布式事务提交回滚效率极低,为了保证高并发,我们下订单通过软一致性让订单创建服务出现问题由本地事务控制回滚,在出现异常回滚的同时我们给消息中间件发送消息通知库存服务对锁定的库存通过反向补偿的方式进行回滚,订单服务只需要给消息队列发送消息,无需等待多个远程调用回滚完毕,即订单创建服务的性能损失几乎没有

      • 我们给库存服务专门设置一个解锁库存的业务,库存解锁发起方给消息中间件对应库存服务的专门存储解锁库存消息的队列发送解锁库存消息,库存服务监听到解锁库存消息就在后台自己去慢慢地解锁库存,无需保证强一致,只需要保证一段时间后最终一致即可

    • seata目前在AT模式下不支持批量插入记录,也不支持MP的addBatch方法,AT模式下只能一条一条数据循环遍历来插入,很消耗数据库性能https://blog.csdn.net/qq_33240556/article/details/140790581

 

异步确保型方案



 

  1. 订单业务逻辑

  2. 带回滚的锁库存逻辑

    • 数据库mall-sms中的表sms_stock_order_task记录着当前那个订单正在锁库存,在表sms_stock_order_task_detail表中记录着商品id、锁定库存的数量、锁定库存所在仓库id、订单任务号;即锁库存的时候先给数据库保存要锁定的库存记录,然后再锁定库存;只要锁定库存成功,库存相关的三张表因为本地事务都会成功保存锁库存记录;如果锁失败了数据库因为事务不会有锁库存记录,库存也不会锁定成功

    • 这样库存锁定表中存在的就是下单成功锁定库存的记录和下单失败但是锁定库存成功的记录,我们可以考虑使用一个定时任务,每隔一段时间就扫描一次数据库,检查一下哪些订单没有创建但是有锁定库存的记录,把这些锁定库存记录拿出来重新把库存补偿回滚一下,但是使用定时任务来定期扫描整个数据库表是很麻烦的一件事,我们通过引入延时消息队列来实现定时功能

    • 延时队列的原理是当库存锁定成功以后我们将库存锁定成功的消息发送给延时队列,但是在一定时间内消息被暂存在延时队列中不要往外发送,即锁定库存成功的消息暂存在延时队列中一段时间,在订单支付时间过期以后我们将该消息发送给解锁库存服务,解锁库存服务去检查订单是否被取消,如果订单根本没有创建或者因为订单未支付而被自动取消了就去数据库根据对应的锁库存记录将库存解锁,即锁定的库存在订单最大失效时间以后才固定进行解锁,通过延时消息队列来控制这个定时功能

    • 延时队列锁库存的业务流程

      • 1️⃣:创建订单锁定库存成功后,给主题交换器stock-event-exchange发送库存工作单消息,消息的路由键stock.locked,被主题交换器路由到队列stock.delay.queue中等待50分钟过期时间

      • 2️⃣:库存工作单在消息存活时间到期以后,队列将消息的路由键更改为stock.release,通过主题交换器stock-event-exchange将消息路由到队列stock.release.stock.queue,由该队列将消息转发给消费者库存服务

      • 3️⃣:消费者库存服务收到库存工作单消息,检查订单服务对应订单的状态,如果订单未被成功创建或者订单未支付就解锁被锁定的库存

    • 解锁库存逻辑

      • 需要解锁库存的场景:

        • 创建订单成功,订单过期没有支付被系统自动取消或者订单被用户手动取消时需要解锁库存

        • 创建订单过程中,远程调用库存服务锁定库存成功,但是调用其他服务时出现异常导致创建订单整个业务回滚,之前成功锁定的库存就需要自动解锁来实现回滚,使用Seata分布式事务性能太差,不适合下单这种高并发场景;基于柔性事务的可靠消息加最终一致性的分布式事务方案,在保证分布式事务下的性能同时,允许一定时间内的软一致性并确保库存数据的最终一致性

      • 只要库存锁定成功就给RabbitMQ中对应的库存延迟队列发送库存工作单消息,使用RabbitTemplate.convertAndSend()发送锁定库存消息,同时锁定库存以前我们要保存库存工作单信息[对应表wms_ware_order_task,保存订单Id、订单号],锁定存库成功以后要保存库存工作单详情[对应表wms_ware_order_task_detail,给该表添加bigint类型字段ware_id锁定库存所在仓库id;int类型的lock_status,其中1表示已锁定、2表示已解锁、3表示已扣减;注意MP更改了字段需要更改相应的Mapper文件中的resultMap标签;保存商品sku、商品数量、库存工作单id、锁定库存所在仓库id、默认锁定状态是已锁定1],锁定库存前先保存库存工作单,保存库存工作单是为了追溯锁定库存信息

        • 给消息队列发送的消息实体类直接写在common包下,该消息对象StockLockedTo保存库存工作单id、该工作单下所有工作单详情id列表,老师这里发送消息的时机错了,所有商品都锁定成了才给消息队列发送消息,否则本地事务会自动回滚,老师是锁定一个商品就发送一条消息,如果事务回滚了发出去的消息就撤不回来了,而且老师这里发送的消息是全量的库存工作单详情数据

        [消息]

        [锁定库存保存库存工作单并发送消息]

      • 使用@RabbitHandler监听锁定库存消息队列,获取到消息对象,按以下情况执行解锁库存逻辑

        • 1️⃣:创建订单过程中,库存锁定成功,但是接下来创建订单出现问题,整个订单回滚,被锁定的库存需要自动解锁

          • 只要库存工作单详情存在,就说明库存锁定成功,此时我们就要看订单状态来判断是否需要解锁库存,如果订单都没有说明订单没有被成功创建,此时就要使用库存工作单详情来解锁库存

          • 如果有订单查看订单状态,如果订单已经被取消就解锁库存,只要订单没有被取消就不能解锁库存,订单被取消字段status等于4

            • 根据库存工作单的id去订单服务查询订单实体类,如果订单不存在或者订单的status字段为4就调用unLockStock方法解锁库存

        • 2️⃣:订单创建失败是由于库存锁定失败导致的

          • 库存工作单数据没有是库存本地事务整体回滚导致的,库存工作单记录不会创建,锁定库存操作也会全部自动回滚,这种情况无需解锁

        • 解锁库存需要知道商品的skuId、锁定库存所在仓库id、锁定库存数量、库存工作单详情id,解锁就是将原来的增加的锁定库存的字段再减掉UPDATE wms_ware_sku SET stock_locked = stock_locked + #{num} WHERE sku_id=#{skuId} AND ware_id = #{wareId}

          • 🚁:自动应答的消息队列,一旦在消息的消费过程中出现异常导致消息无法被正常消费,消息就丢失了,比如Feign远程调用网络闪断,或者远程服务的Feign调用必须携带用户的登录状态但是实际请求没有携带用户登录状态被远程服务拦截,抛出异常终止后续方法执行,此时消息就彻底丢失了

          • 📓:使用配置spring.rabbitmq.listener.simple.acknowledge-mode=manual开启消息接收手动应答,在订单解锁成功以后使用方法channel.basicAck(message.getMessageProperties().getDeliveryTag(),false)来做消息接收手动应答,解锁成功立马手动应答,无需解锁什么也不用做立马手动应答,只要在应答后发生异常也不会导致消息丢失;如果远程调用没有成功返回在库存解锁以前出现问题,我们使用方法channel.basicReject(message.getMessageProperties().getDeliveryTag(),true)来手动拒绝消息并将消息重新放到队列中,给别人继续消费消息解锁库存的机会,比如由于分区故障为了保证一致性部分服务不可用

        • :订单服务所有远程调用请求都要求有登录状态,但是我们的消息队列监听方法的远程调用不可能带用户登录状态,因此我们需要在订单服务的拦截器中放行所有消息队列监听方法调用的订单服务远程接口,特别注意,这个需要被放行的请求路径还拼接了请求参数导致请求路径是动态的

          • 🔑:我们通过在拦截器中放行指定URI的请求来实现这个目的,对于URI是变化的我们使用Spring提供的boolean match = AntPathMatcher.match("/order/order/status/**",request.getRequestURI())[HttpServletRequest.getRequestURI()是获取请求路径的URI,HttpServletRequest.getRequestURL()是获取请求路径的URL],如果请求URI匹配我们需要的格式就直接通过拦截器的return true放行,无需再进行用户登录状态检查

        • 专门抽取一个消息队列的监听器Service来处理消息队列中的消息

          • 在类上标注@RabbitListener(queues="stock.release.stock.queue")来监听指定队列,在类上标注@Service注解将该类的实例化对象作为容器组件,在具体的方法上标注注解@RabbitHandler,在该方法中调用库存服务实现的解锁库存逻辑,解锁库存的方法出现任何异常都手动拒绝消息并重新入队列,只要解锁库存方法成功调用就手动应答接收消息,远程调用如果状态码不是0说明没有查到对应订单的实体类,此时直接抛异常执行拒绝接收消息的逻辑

          [监听队列消息]

          [解锁库存]

        • 解锁库存成功后通过库存工作单详情id将库存工作单详情的状态lock_status更改为已解锁2,增加前面解锁库存的条件只有库存工作单详情为已锁定状态且需要解锁时才能解锁库存

      • 带回滚的锁库存实现

        • 给库存服务mall-ware引入、配置RabbitMQ并在主启动类上标注@EnableRabbit开启RabbitMQ功能

        • 配置RabbitMQ的消息的JSON序列化机制

        • 给库存服务添加一个默认交换器stock-event-exchange

          • 交换器使用Topic交换器类型,因为该交换器需要绑定多个队列,而且还需要使用对不同消息的路由键进行模糊匹配的功能

        • 给库存服务添加一个释放库存队列stock.release.stock.queue,支持持久化,不支持排他和自动删除,普通队列不需要设置参数

        • 给库存服务添加一个延迟库存工作单消息的队列stock.delay.queue,给该延时队列设置死信交换器stock-event-exchange,设置死信的路由键为stock.release,设置队列的消息存活时间为120秒[方便测试用的,比验证订单创建的延迟队列多一分钟],支持持久化,不支持排他和自动删除

        • 给库存服务的库存释放队列和交换器添加一个绑定关系,绑定目的地stock.release.stock.queue,交换器stock-event-exchange,绑定键stock.release.#

        • 给库存服务的库存延时队列和交换器添加一个绑定关系,绑定目的地stock.delay.queue,交换器stock-event-exchange,绑定键stock.delay.#

        • 监听一个队列让以上所有容器组件都通过SpringBoot自动去RabbitMQ中检查创建

        • 取消订单逻辑

          • 订单服务队列和交换器组件和绑定关系

          • 订单创建成功就给交换器order-event-exchange发送消息,消息路由键order.create.order,保存的消息是OrderCreateTO.getOrder(),消息会被交换器路由到order.delay.queue[队列中的消息延时时间为30min],消息变成死信后路由键配置成order.release.order并将消息转发到order-event-exchange路由到order.release.order.queue被订单服务监听,订单服务监听接收取消订单并将消息转发以路由键order.release.other通过交换器order-event-exchange,队列将消息转发给订单服务

            [订单服务创建订单发起消息]

            [订单服务接收消息取消订单并向库存服务发送消息]

          • 订单服务收到消息根据订单id查询数据库对应的订单状态,如果订单状态为订单创建对应的状态码,将订单状态更改为取消订单对应状态码

          • 通过监听消息队列消息在取消订单或者订单创建失败的情况下解锁库存

            [消息队列监听]

            [解锁库存]

          • 我们这里是用库存解锁时间大于取消订单时间来实现解锁库存只要订单的状态为已取消或者订单没有成功创建,就释放已经锁定的库存,但是这种方式存在很严重的问题;比如订单创建成功,但是由于各种原因,消息延迟了很久才发给消息队列,但是库存一锁定成功就将消息发送给消息队列了,导致解锁库存的消息比取消订单的消息先到期,这时候就会导致解锁库存的消息被消费,库存因为订单处于新建状态无法解锁,即使后续订单被解锁了库存也无法被解锁了;即一旦发生意外导致解锁库存的消息比取消订单的消息先到,就会发生被锁定的库存永远无法解锁的情况

            • 🔑:让订单服务取消订单后再发一个消息路由键为order.release.other给交换器order-event-exchange,我们为交换器order-event-exchange和队列stock.release.stock.queue设定绑定关系,绑定关系设定为order.release.other.#,让取消订单的消息被队列stock.release.stock.queue发送给消费者库存服务。库存服务用@RabbitListener(queues="stock.release.stock.queue")监听同一个队列stock.release.stock.queue,用@RabbitHandler标注的方法监听消息类型为OrderTo,在原来解锁库存的逻辑中判断,当前库存是否解锁过,没解锁过就解锁,解锁过就不用解锁了,老师的逻辑是根据订单号查询库存工作单,根据库存工作单找到所有没有解锁的库存工作单详情调用此前解锁库存的方法进行解锁,感觉这里老师的实现不好,自己实现这部分代码

        • 实际上解锁库存是订单取消的时候解锁一次,锁定库存成功以后一定时间再解锁一次

       

      消息可靠性投递
      1. 影响消息可靠性的因素

        • 消息丢失:消息丢失在电商系统中是一个非常可怕的操作,比如订单消息丢失可能会影响到后续一连串比如商家确认、解锁库存、物流等等各种信息,消息可能发生丢失的原因如下:

          • 消息从生产者发送出去,但是由于网络问题抵达RabbitMQ服务器失败,或者因为异常根本没有发送成功

            • 这时候可以用try...catch语句块来发送消息,发送失败在catch语句块中设置重试策略

            • 同时给数据库创建一张消息数据库表mq_message,建表语句如下

            • 只要消息发送失败就给数据库存上这么一条日志,定期扫描数据库来检查消息日志状态来重新发送消息

          • 消息到达Broker,消息只有被投递给队列才算持久化完成,一旦消息还没有到达队列,RabbitMQ服务器宕机消息就会因为还没有来得及持久化而发生丢失

            • 开启生产者消息抵达队列确认,只要消息没有成功抵达队列就会触发生产者的returnCallback回调,消息不能成功抵达应该设置消息重试发送和向数据库记录消息日志

            • 开启生产者消息确认回调,只要消息成功抵达RabbitMQ服务器就触发该回调

          • 自动ACK的状态,消费者收到消息,但是消息没有被成功消费,比如消费消息或者消费消息前出现异常或者服务器宕机,自动应答的消息会直接丢失

            • 开启手动ACK,消息成功消费以后再手动应答接收消息,消息消费失败就手动拒绝消息让消息重新入队列,注意消息没有被应答即没有手动拒绝RabbitMQ没有收到应答的消息也会默认重新入队列再次发送

          • 🔎:防消息丢失的核心就是做好消息生产者和消息消费者两端的消息确认机制,主要策略就是生产者的消息抵达确认回调和消费者的手动应答,凡是消息不能成功抵达服务端和消费端的消息都做好消息日志记录,定期扫描数据库,将发送失败的消息定期重新发送

        • 消息重复:就是因为各种原因导致的消息重新投递

          • 消息消费成功,事务已经提交,但是手动Ack的时候机器宕机或者网络连接中断导致手动Ack没有进行,RabbitMQ的消息因为没有收到应答自动将消息重新入队列并将消息状态从Unack状态变成ready状态,并再次将消息发送给消费者

          • 消息消费过程中消费失败又再次重试发送消息,注意啊,虽然我们让消息消费失败消息拒绝重新入队列

            • 解决办法是业务消息消费接口设计成幂等性接口,比如解锁库存要判断库存工作单详情的状态位,消息消费成功修改对应状态位

            • 使用redis或者mysql防重表,将消息和业务通过唯一标识联系起来,业务被成功处理过的消息就不用再处理了

            • RabbitMQ的每个消息都有一个redelivered消息属性字段,每个消息都可以通过Boolean redelivered = message.getMessageProperties().getRedelivered()判断当前消息是否被第二次或者第N次重新投递过来的,这个一般做辅助判断,因为谁也不能保证消息在第几次消费被消费成功

        • 消息积压:消息队列中的消息积压太多,导致消息队列的性能下降

          • 消费者宕机导致消息积压

          • 消费者消费能力不足,比如活动高峰期,比如消费者宕机导致的消费者集群消费能力不足,有服务完全不可用消息反复重入队列消息肯定会积压,应该设置重试次数,投递达到重试次数消息就被专门的服务处理比如存入数据库离线处理

            • 注意消费者没有应答消费消息,队列中的消息处于Unack状态,生产者会不停报错,让CPU飚高,非常消耗系统性能,这个问题要想办法防一下

          • 发送者发送消息的流量太大,超出消费者的消费能力

            • 限制发送者的流量,让服务限流业务进不来就能限制发送者的流量,不过只是因为消息中间件或者消费者能力有限就限制业务有点得不偿失

            • 上线更多的消费者增强消息的消费能力

            • 上线专门的消息队列消息消费服务,将消息批量从消息队列中取出来,直接写入数据库,缓解消息队列压力,然后再缓慢离线从数据库中获取消息离线处理

            • 消息队列集群

      2. 一般都是把消息中间件专门做成一个服务,叫数据中台,负责消息发送和自动记录消息日志,消息发送失败自动进行重试,将消息发送的所有功能都考虑周到,其他服务通过调用该服务来实现消息的发送,看老师的意思,一般消息发送成功也得记录日志,这个可以作为防止消息丢失更进一步的手段,毕竟会影响性能

      3. 生产者抵达确认带数据库保存失败消息

      4. 消费者手动ACK

      5. 使用MP数据库消息日志记录

        • 同时给数据库创建一张消息数据库表mq_message,建表语句如下

        • MP插入消息记录

          [实体类]

          [持久化接口]

          [持久化接口对应xml]

          [业务实现类]

           

分布式session共享

Session的使用问题

  1. session的原理

    • 原理图

      • 用户登录成功可以将用户相关信息保存到以Map作为底层的session中,并指定浏览器保存一个属性名为JSESSIONIDcookie,以后浏览器访问服务器会携带包含该参数的cookie,直到浏览器关闭才会清除该cookie,该JSESSIONID作为服务器识别用户身份的标识,通过该ID来查询用户在服务器中保存的特定状态信息

  2. 传统session存在的问题

    • 1️⃣:在集群环境下,一个服务会被复制多份,但是运行过程中集群中不同运行实例上的session信息无法被同步,即在一台服务器上被存入session的数据在另一台服务器或者运行实例上无法被取出

    • 2️⃣:在分布式环境下,不同服务间的session不能共享,比如我在认证服务进行的数据认证并需要在跳转首页时验证用户的登录状态并传递用户信息到首页,但是首页在商品服务,我在认证服务存入session的数据无法被共享到商品服务的session

    • 3️⃣cookie的默认的作用域[就是下图的Domain字段]是同一个三级域名下,域名不一样,在发起请求时cookie无法被携带

      • 这里登录页面的cookie可以在原理图中看到,请求域名发生变化,cookie并没有被携带,也无法通过cookie中的JSESSIONID来匹配用户会话状态信息

 

 

 

集群session共享



 

 

子域session共享



 

 

分布式集群session解决方案



  1. SpringBoot基于Redis整合SpringSession解决session跨域跨服务共享问题

    • 🔎Samples and Guides中找到并点击HttpSession with Redis,可以找到对应的使用引导

    • 引入依赖

      • 检查一下SpringSession操作redis需不需要引入org.springframework.boot:spring-boot-starter-data-redis,经过验证,SpringSession依赖于org.springframework.data:spring-data-redis,所以无需再额外引入

      • 为了性能我们可以把默认的lettuce-core排除掉使用最新的io.lettuce:lettuce-core:5.2.0.RELEASE,老版本对内存管理存在问题,并发量一高就会大量抛异常

    • SpringBootSpringSession的配置

      • 选择session的存储介质为redis[必选]

      • 配置session的超时时间[可选,默认配置是30分钟] [默认单位是秒,要指定分钟可以指定成如下格式30m]

      • 配置redissession的刷新策略[可选]

      • 配置redissession存储的前缀[可选] [SpringSession创建的缓存也和使用SpringCache创建的缓存一样会创建相应的目录来管理]

    • SpringBoot配置Redis的连接信息

      • 这个一般在项目中使用Redis就会主动配置

    • Servlet容器初始化原理

      • SpringBoot配置好了一个名为SpringSessionRepositoryFilter的组件,该组件实现了Filter接口,相当于该组件具备过滤器的功能,这个SpringSessionRepositoryFilter将原生HttpSession替换成我们Spring的自定义的session实现,在该实现中

    • 配置组件

      • 组件RedisConnectionFactory已经被SpringBoot自动注入到IoC容器中,

      • 我们只需要在配置类或者启动类上添加注解@EnableRedisHttpSession开启整合Redis作为session存储的功能

    • 卧槽这么牛皮,Spring用自定义session取代了原来Tomcat自带的HttpSession,我们原来操作session都是直接在控制器方法的参数列表指定HttpSessionSpring容器自动进行注入tomcat原生的HttpSession,我们使用Tomcat原生的HttpSession的API来操作Session,现在Spring使用自定义的SpringSessionRepositoryFilter来替换Tomcat原生的HttpSession,我们在控制器方法注入HttpSession时会自动注入SpringSessionRepositoryFilter,而且SpringSessionRepositoryFilter操作sessionapiHttpSession是一样的,这意味着我们可以不需要更改代码只需要配置SpringSession就能丝滑使用SpringSession替代Tomcat的原生HttpSession,原来对session的操作一样生效,只是更换了方法的具体实现,把session存到redis中去了,下发cookie的时候也将对应的作用域设置为了顶级域名,这个就是Java中多态的思想,猜测HttpSession是一个接口,Tomcat的原生HttpSession只是其中一个实现类

      • 经过确认,确实如此,javax.servlet.http.HttpSessiontomcat-embed-core:9.0.24中的包下的一个接口,下面有多个实现类,Tomcat默认使用的是StandardSession

    • 做完以上的步骤,在执行操作session的方法时仍然会报错SerializationException,原因是在执行session操作的时候无法进行序列化,这是因为我们要操作一个对象,将对象从当前服务器内存中保存到第三方存储介质中,这个过程涉及到IO过程,暂时认为所有的IO过程都要对内存中的对象进行序列化后才能传输,序列化的目的是将一个内存中的对象序列化为二进制流或者串,我这里先肤浅地认为只有二进制流或者串才能执行IO操作,具体的原理以前讲的很浅,后面看JavaIO中有没有补充

      • 核心就是要使用SpringSession操作的数据因为要存储到第三方公共存储介质中,需要被操作的数据能够被序列化,SpringSession默认使用JDK序列化,JDK的默认序列化需要被序列化的对象对应的类实现序列化接口才能将对应的对象进行序列化

        • 注意RedisTemplate的序列化实现好像使用的是注入序列化器来对缓存的数据专门进行序列化,那个好像没有专门要求被缓存的数据需要实现序列化接口

    • SpringSession在第一次执行session操作后,会给客户端下发一个名为SESSIONcookie,该SESSION令牌会替代原来的JSESSIONID令牌

      • 注意默认情况下,SpringSession设置的作用域也是当前二级域名;

      • 同时,跨服务使用SpringSession基于Redis来共享session,两个服务都需要引入spring-session-data-redis

      • :我们在mall-authmall-product两个服务都引入spring-session-data-redis,并将在mall-auth即请求域名为auth.earlmall.com下发的cookie的作用域手动改成earlmall.com,但是此时我们在mall-product中获取mall-auth存入的session数据仍然报错SerializationException

      • 🔑:经过分析,这是因为我们在mall-product中要从redis中获取被序列化的数据,并且要将该数据反序列化为对象,结果在反序列化的过程中在mall-product中找不到数据对应的类,即SerializationException是由ClassNotFoundException导致的,因此需要在分布式集群中进行session共享的类最好放在common包下;

        • 同时数据对象在被序列化的时候,会在序列化结果中保存序列化前的对象对应的全限定类名,因此直接将对应的类向使用session数据的目标服务拷贝一份也是不行的,因为全限定类名不同,直接放在common包下最保险,而且缓存中的全限定类名与实际类名不同,反序列化也会失败,实体类的全限定类名发生了变化一定要清空缓存

    • :​目前使用SpringSession基于公共第三方Redis存储session数据解决了session跨服务共享的问题,但是目前存在两个问题,第一个问题是下发cookie的作用域仍然是对应下发cookie请求的二级域名,无法解决子域session共享问题,第二个问题是SpringSession默认使用的是JDK自带的序列化器,我们希望能够使用字符串序列化器将对象序列化为json对象存储在Redis中,这样也方便我们自己查看一些出问题的session数据

    • 🔑:我们可以通过自定义SpringSession来解决该问题

 

自定义SpringSession



  1. SessionConfig.java

    • 通过给容器中注入RedisSerializer替换掉SpringSession默认的序列化机制就能把原来使用JDK默认的序列化器换成JSON序列化器

      • 使用了自定义JSON序列化器,不使用JDK默认的序列化器,实体类可以无需实现Serializable接口

  1. 配置CookieSerializer

    • 配置cookie的最大有效时间[默认配置是Session,即浏览器一关cookie就失效]

    • 配置cookie的作用域为顶级域名[默认配置是二级域名,我们手动扩大这个作用域来实现session跨域共享]

    • 配置第一次使用session默认下发cookie的名字

    • 这个待补充

  2. 配置示例

    • SpringSession基于Redis的配置比较麻烦啊,因为SpringSession要使用Redis,但是有些服务不需要使用Redis。所以需要使用Session数据的服务就需要搭建SpringSessionRedis的环境,如果直接配置在Common包下也会显得比较臃肿

     

     

SpringSession核心原理



  1. @EnableRedisHttpSession

    • 注意:在spring-session 2.2.1版本的时候,放入的不是RedisOperationsSessionRepository了,而是RedisIndexedSessionRepository

    • 核心原理是@EnableRedisHttpSession注解导入配置类RedisHttpSessionConfiguration,该配置类给IoC容器中注入了一个基于Redis增删改查session的持久化层组件RedisOperationsSessionRepository,该配置类还继承自类SpringHttpSessionConfiguration,在该父配置类中给容器注入了一个SessionRepositoryFiltersession存储过滤器,该组件的父类OncePerRequestFilter实现了Filter接口,该session存储过滤器在构造完成时就会将RedisOperationsSessionRepository注入成为属性完成初始化,通过父类OncePerRequestFilter实现的doFilter()方法调用SessionRepositoryFilter自己实现的doFilterInterval()方法,在该方法中即前置过滤器链中将RedisOperationsSessionRepository放到本次请求的请求域中,并将原生的HttpServletRequestHttpServletResponse和应用上下文ServletContext包装成相应的请求包装类SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper和响应包装类SessionRepositoryFilter.SessionRepositoryResponseWrapper,并在放行过滤器链的filterChain.doFilter(wrappedRequest, wrappedResponse);方法中传参对应的请求和响应包装类,即后续的过滤器链和业务方法都是处理的请求和响应的包装类,我们在控制器方法中获取的HttpSession组件本质是Spring通过httpServletRequest.getSession()方法获取的session对象,当我们使用SpringSession并使用注解@EnableRedisHttpSession开启SpringSession功能后就在前置过滤器链中将原生的HttpServletRequest替换成了同样实现了HttpServletRequest接口的包装类SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper,实际上调用的getSession方法是包装类实现的,在该方法中通过持久化层组件RedisOperationsSessionRepository来实现对session的基于Redis的持久化操作并最终得到session对象

    • 持久化层SessionRepository也是一个接口,我们使用的第三方存储介质是redis,而且导入的是基于redis的SpringSession场景启动器spring-session-data-redis,因此默认使用的是子接口FindByIndexNameSessionRepository下的唯一实现类RedisOperationsSessionRepository,此外还有直接实现类MapSessionRepository使用内存来保存session;此外如果我们导入基于JDBC的SpringSession场景启动器还可以使用数据库来保存session,如果导入基于MongoDBSpringSession场景启动器我们也可以使用MongoDB来保存session,也会有相应的数据库持久层

    • 以上原理就是典型的装饰者模式的应用,实现代码的非侵入性修改

     

支付业务

 

支付宝支付



生产环境

  1. 接入流程

    • 正常的接入流程要按照接入准备的流程接入准备文档]

      • 1️⃣:创建应用[创建一个已经上线的应用]

      • 2️⃣:在应用列表下添加要接入的功能

      • 3️⃣:设置接口加密方式、IP白名单和网关等开发设置

      • 4️⃣:接入功能签约[需要营业执照]

 

沙箱环境

  1. 沙箱环境

    • 沙箱环境是一个支付宝内部的安全环境,模拟了所有的支付宝开放平台的功能,正式接入需要提供营业执照和已经上线的业务进行审核,应用没有上线前可以使用沙箱环境来进行调试,相当于支付宝给每个开发人员创建的一个应用

  2. 配置沙箱环境

    • 通过沙箱环境文档链接https://opendocs.alipay.com/common/02kkv7搜索沙箱控制台点击进入沙箱控制台,里面可以看到当前开发人员沙箱环境对应的配置信息

    • 配置参数

      • APPID:沙箱环境的应用ID

      • 支付宝网关地址gatewayUrl:支付需要调用的支付宝网关接口地址,实际生产项目需要将沙箱网关切换为支付宝的线上网关,支付宝沙箱网关https://openapi-sandbox.dl.alipaydev.com/gateway.do会在alipay后面加一个dev

      • merchant_private_key:商户私钥,也叫应用私钥,现在是直接支付宝开放平台线上生成好的,无需像以前一样下载支付宝开放平台开发助手来生成,老师那时候还是下载该应用手动生成的,以前应用公钥在本地生成以后还要上传到支付宝开放平台,现在也不需要管了

      • alipay_public_key:支付宝公钥,线上支付宝开放平台直接生成好的

    • 沙箱账号:这是一个付款账号,支付宝支付需要使用该沙箱账号进行支付,该沙箱账号有100w余额,就是支付环境的买家测试账号,还可以给沙箱账号充值

      • 买家账号:该沙箱环境只能使用该买家账号登录支付宝进行付款

      • 登录密码:买家支付宝账户的登录密码

      • 支付密码:买家支付宝账户的支付密码

    • 页面跳转地址

      • return_url:页面跳转同步通知页面路径,支付宝支付成功以后用户要跳转的页面地址,DEMO中的地址为http://localhost:8080/alipay.trade.page.pay-JAVA-UTF-8/return_url.jsp

      • notify_url:服务器异步通知页面路径,支付宝支付成功以后会每隔几秒给服务器该接口地址发送一条支付成功的消息来通知服务器用户支付成功了,服务器接收到消息可以根据支付信息对订单进行后续处理http://localhost:8080/alipay.trade.page.pay-JAVA-UTF-8/notify_url.jsp

    • 设置全局的编码格式为UTF-8,避免因为文件编码格式错误导致支付宝返回页总是报错签名错误等各种错误

 

内网穿透


  1. 原理

    • 内网穿透服务商会要求在我们的电脑上下载一个服务商软件,该软件可以和服务商服务器建立长连接,内网穿透服务商会为我们电脑上的服务商软件分配一个随机的无需备案的域名[这个域名可能很丑很难看]

      • 临时分配的域名一般是内网穿透服务商的二级或者三级域名,只要内网穿透服务商的顶级域名备了案,子域名无需再备案

      • 别人访问临时域名如haha.hello.com会先到达内网穿透服务商,服务商根据域名找到分配对应域名的服务软件,通过软件与服务器间建立的长连接通道将请求直接转发到我们的电脑

      • 同理其他电脑也可以通过这种方式让我们能正常访问其他的电脑

      • 最终效果就是使用内网穿透服务商分配的域名实现在公网上通过域名访问我们的主机的效果

  2. 使用场景

    • 开发测试,比如微信和支付宝的开发调试

    • 智慧互联,我们在外面无法直接通过公网访问到我们的家用电脑和智能设备,但是我们可以通过内网穿透服务商给路由器分配一个域名,我们可以在任何地方通过该域名找到路由器并给路由器发送命令控制内网中的设备,做智慧家庭云系统

    • 私有云,家庭系统中添加一个远程访问的私有存储数据设备

  3. 使用流程

    • 下载服务商软件,这里用natapp演示

 

电脑网站支付



  1. DEMO演示

    • 下载DEMO压缩包alipay.trade.page.pay-JAVA-UTF-8.zip

      • 注意该项目解压以后是一个典型的Eclipse应用,只是一个普通工程,不是Maven工程

      • 在Eclipse左侧菜单栏右键--Import--General/Existing Projects into Workspace--Browse选择刚解压的文件夹--选中要引入的项目--设置Options为Copy projects into workspace--Finish将项目导入Eclipse

      • 该项目运行需要Tomcat服务器,项目导入以后会在Servers界面提示No servers are available,选择Tomcat来创建一个server服务器,老师选择的是Tomcat 8.5

    • 项目代码

      • src目录下只有一个配置类AliPayConfig,使用支付宝电脑网站支付需要在AliPayConfig中做很多的配置,只有清除每一个配置的含义才能把项目搭建起来,配置如下

      • 该DEMO的所有代码都在WebContent目录下,所有的代码包括前端代码都被统一放在JSP页面中了,要做支付直接把对应JSP的代码复制粘贴处理成我们自己的页面即可

  2. 核心概念

    • 身份:

      • app_id:使用支付宝需要创建应用,创建的应用在管理中心的网页和移动应用列表,在应用列表中可以看到每个应用列表的APPID,在每个应用详情的图标下方也会显示应用的APPID,作为当前应用的唯一标识

    • 加密:服务器和支付宝之间会传递金融数据,对网络传输的加密要求比较高,我们在配置文件中配置的就是非对称加密算法的客户端的请求加密密钥和响应解密密钥

      • 🔎:根据非对称加密算法支付宝和一个客户端之间总共有两对即四把密钥,RSA算法一次生成一对密钥,一把公钥、一把私钥;

      • 加密过程:商户保存商户公钥,发起请求时结合请求参数和商户请求标识使用商户公钥加密生成一个签名,该签名只要请求参数发生变化签名就会发生变化[比如一个藏在签名中的本次操作标识拼接请求参数整体做MD5加密用商户公钥做成签名,网络传输过程中不法组织没有商户私钥获取不到签名中的本次操作标识无法伪造签名,也无法使用以往签名替代本次签名,我们就可以通过签名来验证用户参数是否发生过篡改,即使不法组织直接用请求参数密文替换当前的请求参数对应密文签名验证也无法通过],服务器接收数据验证签名后处理业务,业务处理完成使用支付宝私钥结合本次操作标识和响应参数生成签名,该签名可以在网络传输过程被泄露的支付宝公钥解密[如果能被篡改可以根据客户端的逻辑比如操作失败用户可能还会继续选择重试],但是不法组织即使篡改了数据也因为没有私钥无法为篡改后的数据加密,也无法用以往交易的唯一标识结合错误响应结果的签名来替换本次的响应密文,因此请求和响应数据都是安全的,响应数据到达客户端以后使用支付宝公钥来解密响应密文

        • 这里老师的逻辑是有漏洞的,因为签名也是可以替换的,除非服务器和客户端在发起请求以前有公共的唯一标识,这个唯一标识第三方还无法破解才能保证签名对应的密文不会被替换[这里结合JWT的思路可能会更清晰,使用随机数是不行的,因为随机数就算存在签名中无法被获取,但是签名也能整体被替换,只要把以前的请求参数和签名密文整体换掉就验不出来,除非交易前客户端和服务端都保存了标识本次交易的唯一标识并用该标识生成签名]

        • 注意这里有歧义,支付宝的加密是私钥进行加密,公钥进行解密;不是保存在生产者中的是私钥

        • 注意只有支付宝私钥是没有人能看到的,其他的所有3把密钥商户都能看到,反正只要不知道支付宝私钥,整个通信过程就是安全的

      • merchant_private_key:商户私钥,也叫应用私钥,现在是直接支付宝开放平台线上生成好的,无需像以前一样下载支付宝开放平台开发助手来生成,老师那时候还是下载该应用手动生成的,以前应用公钥在本地生成以后还要上传到支付宝开放平台,现在也不需要管了

      • alipay_public_key:支付宝公钥,线上支付宝开放平台直接生成好的

    • 沙箱账号:这是一个付款账号,支付宝支付需要使用该沙箱账号进行支付,该沙箱账号有100w余额,就是支付环境的买家测试账号,还可以给沙箱账号充值

      • 买家账号:该沙箱环境只能使用该买家账号登录支付宝进行付款

      • 登录密码:买家支付宝账户的登录密码

      • 支付密码:买家支付宝账户的支付密码

    • 页面跳转地址

      • return_url:页面跳转同步通知页面路径,支付宝支付成功以后用户要跳转的页面地址,DEMO中的地址为http://localhost:8080/alipay.trade.page.pay-JAVA-UTF-8/return_url.jsp

      • notify_url:服务器异步通知页面路径,支付宝支付成功以后会每隔几秒给服务器该接口地址发送一条支付成功的消息来通知服务器用户支付成功了,服务器接收到消息可以根据支付信息对订单进行后续处理http://localhost:8080/alipay.trade.page.pay-JAVA-UTF-8/notify_url.jsp

    • 设置全局的编码格式为UTF-8,避免因为文件编码格式错误导致支付宝返回页总是报错签名错误等各种错误

  3. 整合支付宝支付功能

    • 页面跳转

      • 点击支付宝付款发起POST请求跳转地址alipay.trade.page.pay.jsp

      • alipay.trade.page.pay.jsp

    • 整合流程

      • 1️⃣:引入支付宝支付SDK,即com.alipay.sdk:alipay-sdk-java,版本与老师保持一致

      • 2️⃣:将DEMO中的支付请求发起逻辑代码和支付宝支付配置类AliPayConfig封装成一个工具类AlipayTemplate

        • 支付宝的响应内容即result的内容如下

          • 响应的是一个表单,该表单封装了用户支付的所有数据,用户浏览器只要一收到该表单,就会执行脚本document.forms[0].sumbit()直接提交该表单给支付宝,支付宝会直接响应给用户对应的收银页面

          • 因此我们直接将支付宝返回的响应体直接返回给用户,用户客户端就会直接提交表单给支付宝,然后支付宝直接向用户客户端响应收银页面

          • 要特别注意,因为我们响应给用户的数据是支付宝发过来的表单数据,不是一个json数据,@ResponseBody是将对象转换成json格式数据响应并更改响应头中的数据类型为application/json,因此在使用@ResponseBody响应字符串对象的同时我们还要通过@GetMapping(value="/order/pay",produces="text/html")或者@GetMapping(value ="/order/pay", produces = MediaType.TEXT_HTML_VALUE)来指定响应数据的类型为text/html,这样浏览器就不会将数据作为application/json数据展示,而是直接作为HTML页面开始渲染

        [AlipayTemplate]

      • 3️⃣:准备VO类封装支付参数

      • 4️⃣:在支付页给支付宝图片设置一个超链接,超链接的跳转地址th:href="'http://order.earlmall.com/order/pay?orderSn='+${pay.orderSn}"

      • 5️⃣:处理支付逻辑

        • 支付只需要调用alipayTemplate.pay(payVo)传参PayVo封装的支付参数即可,需要传参订单号,订单备注、订单主题[订单主题会在用户付款页显示]、订单金额;前端传参只传了订单号,我们希望从数据库中查询出对应的订单以上信息

          • 实际上这里还应该校验订单的支付状态,支付过了就不允许支付了,避免用户重复支付

        • 注意支付宝要求支付金额必须精确为两位小数,小数位多了少了都会直接报错,通过bigDecimal.setScale(2)设置小数位数为2位,取两位小数时我们可以通过bigDecimal.setScale(2,BigDecimal.ROUND_UP)让金额最后一位向上取值,而且注意支付宝要求的支付金额数据类型是String类型,可以通过bigDecimal.toString()BigDecimal类型数据转换成String类型数据

        • 老师的订单主题是直接拿订单项列表的第一个的商品sku名称作为支付订单的主题,这有点low

        • 订单备注老师也是直接拿第一个订单项的销售属性来糊弄的

        • 响应的是一个表单,该表单封装了用户支付的所有数据,用户浏览器只要一收到该表单,就会执行脚本document.forms[0].sumbit()直接提交该表单给支付宝,支付宝会直接响应给用户对应的收银页面

        • 因此我们直接将支付宝返回的响应体直接返回给用户,用户客户端就会直接提交表单给支付宝,然后支付宝直接向用户客户端响应收银页面

        • 要特别注意,因为我们响应给用户的数据是支付宝发过来的表单数据,不是一个json数据,@ResponseBody是将对象转换成json格式数据响应并更改响应头中的数据类型为application/json,因此在使用@ResponseBody响应字符串对象的同时我们还要通过@GetMapping(value="/order/pay",produces="text/html")或者@GetMapping(value ="/order/pay", produces = MediaType.TEXT_HTML_VALUE)来指定响应数据的类型为text/html,这样浏览器就不会将数据作为application/json数据展示,而是直接作为HTML页面开始渲染

        • 我们模拟用户支付的时候必须使用沙箱账户支付

        • 电商网站用户成功支付以后已经跳转用户的支付列表页,也就是上面用户同步通知页return_url

        [控制器方法]

        [业务实现类]

      • 6️⃣:支付成功跳转

        • 用户支付成功我们希望跳转用户的订单列表页,我们把订单列表页做在会员系统中,设置支付的return_url=http://user.earlmall.com/user/order/list.html,当用户支付成功以后支付宝会重定向回商户的该界面,并且会在该地址后面拼接用户支付的订单号以及支付的签名,我们可以在响应该重定向页面的同时拿着订单号和支付宝签名验证用户支付状态并修改订单状态,主要是验证支付宝签名,只有支付宝签名正确的情况下才说明订单号是正确的,用户确实完成了支付,但是我们有更好的实现方法[如果靠用户浏览器重定向更改订单状态如果用户支付以后立即关闭浏览器我们就收不到用户浏览器发起的重定向请求,因此这种方式更改订单状态不可靠]

      • 7️⃣:异步通知更改订单状态

        • 用户成功支付以后支付宝会每隔几秒就给我们提供的服务器异步通知路径notify_url发起POST请求,将支付结果作为参数通知商户,所有的参数和介绍都在https://opendocs.alipay.com/open/270/105902?pathHash=d5cd617e

        • 程序执行完毕本次请求必须给支付宝响应字符串success这七个字符[不能响应页面,只能响应字符串对象],否则支付宝会不断发起该请求,会在25小时内发起8次请求,发送时间间隔分别为4m10m10m1h2h6h15h,这种事务就是跨系统间的分布式事务,支付宝负责最大努力通知我们支付成功,我们用软一致性还保证数据的最终一致性,支付宝采取商户手动应答的策略来确保消息不容易丢失,这是最大努力通知型方案,不是一定保证最终一致性的方案

        • 因为支付宝要访问我们的接口,因此我们必须保证自己的接口能在外网访问,因此必须使用内网穿透的网址来让支付宝能在公网上访问到我们的接口地址notify_url=http://内网穿透域名/order/paid/notify,把内网穿透域名配置成内网主机域名order.earlmall.com,端口设置成80端口,让请求内网穿透到本机的虚拟机上的nginx上,

          • 但是内网穿透不是浏览器发起的请求,没有携带请求头,或者就算携带了请求头也是携带的内网穿透服务商分配的域名,nginx无法从请求头中获取我们指定的Host信息order.earlmall.com,给内网穿透域名商配置的也只是为了让内网穿透域名商找到内网IP和对应服务端口,Nginx无法根据请求的域名来路由用户请求到网关[因为外网请求实际访问的是域名服务商分配的域名],我们可以在Nginx中做一个精确配置[Nginx优先进行精确匹配],让指定URI为/payed/的所有支付宝请求直接转发到商城网关,请求经过nginx不再携带原来的Host地址,选择使用自定义的Host地址proxy_set_header Host order.earlmall.com;

          • 经过验证,内网穿透内网穿透服务商只是转发原来的用户请求,原来用户请求的HOSt就是域名服务商分配的二级域名,域名服务商并没有将其改成我们自己的内网域名或者IP

          • 同时server_name还要配置请求的域名为内网穿透域名,试一下不配置会不会报错

        • 接口必须为POST方式,必须使用@ResponseBody或者@RestController响应字符串对象

        • 拦截器放行对应接口,不检查登录状态

         

 

用户同步通知页


  1. 部署页面[老艺能,不多说,闭着眼睛都能搞]

    • 动静分离

    • Thymeleaf渲染

    • 视图页面跳转

    • 配置本地域名映射,商城网关对域名user.earlmall.com的跳转

    • 把所有订单页面按钮路由到订单列表页

  2. 为用户服务配置用户登录拦截器并将拦截器注册到容器组件中

    • 用户状态是使用SpringSession来协调存储的,要用拦截器必须要引入SpringSession,否则无法从本地session中获取到用户的登录状态

    • 还要把Session的相关配置比如json序列化器、session过期时间等拷贝到用户服务中

    • 配置redis

    • 计算运费的时候调用了用户服务,而且那个是做收货地址的地址查询不需要做用户登录检查,在烂机器中排除掉对应的接口地址

  3. 编写后端接口远程调用订单服务分页查询用户所有的订单数据

    • renren-fast生成的分页查询接口太粗糙,需要对该功能进行扩展,查询条件包括

      • 用户ID等于当前登录用户的id

      • 用户服务设置Feign请求拦截器,将当前登录用户的cookie设置到远程调用请求的请求头中

      • 查询到的订单数据按照订单自增id降序排列,这样总是能拿到最新的订单数据

      • 只有订单数据不够,还需要订单下的所有订单项数据,订单数据封装在IPage的records属性中,是一个list集合,我们可以取出每个订单数据按照订单号查出每个订单下的所有订单项并重新封装覆盖原来的records属性

      • 分页参数使用Map<String,Object>进行封装,传参当前页码,没有传参当前页码就默认第一页;

      • 用户服务将查询到的数据放到ModelAndView请求域中

      • 注意请求数据要使用@RequestBody来从请求体中获取必须使用POST请求方式,可以使用@PostMapping,也可以使用@RequestMapping[检验一下@GetMapping能不能用@RequestBody]

      • 给数据库表对应实体类添加数据库没有的字段需要使用注解@TableField(exist=false)表示数据库内没有该字段

      • 要获取到分页数据的总记录数和总页数,需要配置MyBatisPlus的拦截器[就是MyBatisPlus的分页插件],这一块可以完全参考以前的多条件检索分页查询的接口做,包括拦截器也直接参考那个,老师这里没有做多条件查询匹配,其实做的并不好

  4. 页面渲染

    • 将订单数据渲染到table列表组件中

      • 第一个tr标签是订单信息

        • 订单号

        • 商城名称写死

      • 第二个tr标签是订单项信息

        • sku图片

        • sku名称

          • 注意这个用法,设置段落宽度,文字内容超过指定宽度自动换行

        • 商品购买数量

        • 收货人姓名

        • 交易总金额,支付方式

        • 订单状态

        • 遍历显示列表,有些数据比如收货人姓名,交易总额,支付方式、订单状态等只需要显示一次,遍历的时候会导致每行都显示,我们可以通过如下设置来让部分内容跨几个订单项只显示一次

          • th:each="item,itemStatus:order.items"itemStatus可以拿到当前标签的数据遍历状态,其中变量index表示当前正在遍历第几个元素,从0开始;count是已经遍历的元素计数,从1开始;size表示元素总共有几个;我们让只需要展示一遍的数据第一遍遍历的时候显示th:if="${itemStatus.index==0}",第一行的数据直接跨所有的列进行展示th:rowspan="${itemStatus.size}",后续遍历对应组件因为index不为0就不会展示了

          • 右边框掉了,我们通过设置td标签的style属性即style="border-right: 1px solid #ccc"来设置右边框边界线

  5. 业务实现

    • 相关自定义工具类

    [Query]

    • 注意IPage类是MP下的

    [PageUtils]

    [控制器方法]

    [业务实现类]

    [数据渲染]

    [分页组件]

     

 

服务器异步通知


  1. 异步通知的参数

    • 用户成功支付以后支付宝会每隔几秒就给我们提供的服务器异步通知路径notify_url发起POST请求,将支付结果作为参数通知商户,所有的参数和介绍都在https://opendocs.alipay.com/open/270/105902?pathHash=d5cd617e

    • trade_status:交易状态,交易状态包括TRADE_SUCCESS[交易支付成功]TRADE_CLOSED[未付款交易关闭或支付后全额退款]TRADE_FINISHED[交易结束,不可退款]WAIT_BUYER_PAY[交易创建,等待买家付款]

    • out_trade_no:订单号

    • trade_no:支付宝交易号,相当于支付宝为此次交易设置的订单号

  2. 使用Vo类封装支付宝的服务端响应参数

    • 注意请求参数会被自动封装到对应的VO类中,除此以外我们还可以通过httpServletRequest.getParameterMap()来从请求中直接获取参数集合

    • 注意支付宝返回的notify_time数据类型为String类型,直接将该数据类型转成Date类型会报错,我们需要在配置文件中配置spring.mvc.date-format=yyyy-MM-dd HH:mm:ss日期类型才能将指定格式的时间字符串格式化为Date类型;此外我们还可以在对应属性上使用注解@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")SpringBoot2.4以上版本叫spring.mvc.format.date

  3. 处理支付宝的支付返回结果

    • 1️⃣:在数据库表oms_payment_info记录了订单支付流水,字段包括order_sn[订单号]alipay_trade_no[支付宝交易号]total_amount[支付金额]subject[订单主题]payment_status[支付状态]callbackTime[异步通知回调时间]记录流水的作用是每隔一个月就可以和支付宝的支付流水进行一个对照,第一步就是根据支付宝的返回结果直接保存一份支付流水

      • 这里限制了一个订单只有一个流水,限制了id为主键、order_sn订单号为唯一键索引、alipay_trade_no支付宝交易号为唯一键索引

      • 把数据库表订单号的长度更改为64

    • 2️⃣:验证支付宝签名,作用是确保该请求是支付宝给我们发送的数据,验签流程可以参考支付宝的DEMO中的notify_url.jsp

      [notify_url.jsp]

      [业务方法]

      • 这里只要验签成功就更改订单状态

    • 3️⃣:修改订单状态,支付状态payment_status中的状态TRADE_SUCCESS的通知触发条件是商户签约的产品支持退款功能且买家付款成功,状态TRADE_FINISHED的通知触发条件是商户签约产品不支持退款功能且买家付款成功,只要支付状态是这两种状态,我们就将用户的订单状态修改为已支付

    • 代码实现

      [控制器方法]

      [业务方法]

       

       

 

收单


  1. 在支付宝的API列表中可以看到支付宝的可调用接口,点进统一收单下单并支付页面接口可以查看接口的详细信息,包括可以携带的参数和参数说明

    • 在该参数表中可以传参一个time_expire,参数说明为绝对超时时间,格式为yyyy-MM-dd HH:mm:ss,只要到了指定的绝对时间以后订单都无法再支付

    • 该参数列表还可以传参一个timeout_express,参数说明为相对超时时间,可取值返回是1分钟到15天,单位有m分钟、h小时、d天、1c当天[1C的含义是无论交易在当天何时创建都会在0点关闭],注意该参数值不接收小数,我们使用该参数设置关单时间为1m,与订单关闭时间相同,感觉这种实现不好,还是创建订单就指定绝对支付时间比较靠谱

  2. 有可能订单最后一刻支付,订单在服务器异步通知的过程中商户订单关闭库存解锁后,异步通知才到;为了避免这个问题,支付宝提供了手动收单功能,用户只要在关闭订单的同时向支付宝发起收单请求,支付宝就会支付失败,怪不得用户同步通知页面比服务器通知页面慢很多,只要服务器通知没到,用户同步通知就到不了,收单的代码示例在DEMO中的alipay.trade.close.jsp

    [alipay.trade.close.jsp]

    [设置AlipayTemplate收单方法]

    • 老师没做手动收单功能,用的只是自动收单,不是线上要测试出这个效果都难

  3. 每天晚上闲时调用支付宝的交易查询接口,该接口DEMO中也有,也可以查看支付宝提供的API列表查看,下载支付宝对账单,对当天支付订单一一对账

 

 

 

 

加密算法



  1. 对称加密

    • 原理示意图

    • 原理:客户端使用密钥A对明文加密生成密文,服务端使用同一把密钥A对密文解密生成明文

    • 核心:加密和解密使用的是同一把密钥

    • 方案:DES、3DES[TripleDES]、AES、RC2、RC4、RC5、BlowFish

    • 缺陷:一旦密钥被截取或者破解,网络传输中就能随意获取篡改用户请求明文来更改服务端数据,既然知道加密规则就一定能通过密文获取到明文,即便加密解密过程不同也可以通过彩虹表暴力匹配

    • 应用场景

      • 这种加密方式非常不安全,在金融领域根本不能使用

  2. 非对称加密

    • 原理示意图

    • 原理:客户端使用密钥A对明文进行加密生成密文,密文只有在服务器中使用密钥B才能解密出明文,使用密钥A无法再解密出明文;服务端的响应数据使用密钥C加密生成密文,客户端使用密钥D解密生成明文,只有使用密钥C加密的密文才能使用密钥D解密,使用密钥D加密的密文无法被密钥D解密,这样即使第三方截取响应内容篡改以后加密的密文无法被客户端正常解密;密钥B和密钥C都只存在服务器内,不存在丢失的风险

      • 非对称加密算法中,公钥私钥是相对于密钥的生成者来说的,存放在生产者手里只提供给生产者使用的就是私钥,发布出去给各个客户端使用的就是公钥[这里有歧义,复习Nginx的时候确认一下,我查了一下网上是发布出去给客户端使用的就是公钥,但是支付宝的加密模型中是加密的是私钥,解密的是公钥]

      • 注意请求到响应两个过程一共是两对密钥四把钥匙,RSA算法一次生成的密钥就是一对

    • 核心:请求和响应使用不同的两对密钥,加密密钥加密的密文只能被解密密钥解密,解密密钥只能解密加密密钥加密的密文

    • 方案:RSA[SHA1金融领域非常常用的非对称加密算法]、Elgamal、RSA2[SHA256]

    • 缺陷:

      • 非对称加密算法仍然存在缺陷,一些不法组织可能给用户弄一个自己的客户端模拟实际的客户端,用户将服务器需要的数据直接明文传递给不法组织的服务器,不法组织的服务器拿着用户的数据篡改以后来代替用户向实际的服务器发起请求,收到响应以后解密篡改响应数据并响应给用户

      • 不法组织可以直接获取到密文,可以在用户发起请求的同时用已知的参数对应密文替换掉当前请求的参数密文[比如在用户的支付金额后面加两个0来让用户多转一点钱]

        • 这种替换参数密文的问题可以通过商户公钥结合请求参数和时间戳等参数通过摘要算法生成一个加密的签名,通过该签名可以验证用户的请求参数是否在网络传输过程中被篡改过[这里要考虑不法组织能否获取到商户的公钥,但是老师显然是不考虑这个问题的],老师的意思就是直接传递参数密文可以直接通过密文来替换参数,我们通过商户特有参数和请求参数通过商户公钥加密,在服务端收到密文解密以后获取到请求参数后还能检验请求参数是否被篡改过,比如使用MD5算法来检查请求参数是否被篡改过